From 21e1a5cbeffaee7bb0223e3968efd8eb85f2d9bc Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 21 Nov 2018 16:42:47 +0000 Subject: [PATCH] [next] Comment Moderation Actions (#2068) * fix: renamed snake case to camel case * fix: changed case for mutators * fix: renamed all snake case to camel case for db * feat: added support for comment revisions + split comment actions * fix: updated tests * feat: implemented CommentModerationAction * fix: fixed case issues * feat: enabled WeakMap for wordList processsing * chore: npm audit --- package-lock.json | 2271 +++++++---------- package.json | 4 +- .../containers/ReactionButtonContainer.tsx | 5 + .../stream/test/comments/editComment.spec.tsx | 2 +- src/core/client/stream/test/fixtures.ts | 3 + .../passport/strategies/facebook.ts | 2 +- .../middleware/passport/strategies/google.ts | 2 +- .../passport/strategies/oidc/index.ts | 2 +- .../server/graph/tenant/loaders/Actions.ts | 23 + .../graph/tenant/loaders/{auth.ts => Auth.ts} | 0 .../loaders/{comments.ts => Comments.ts} | 10 +- .../tenant/loaders/{stories.ts => Stories.ts} | 0 .../tenant/loaders/{users.ts => Users.ts} | 0 src/core/server/graph/tenant/loaders/index.ts | 10 +- .../server/graph/tenant/mutators/Actions.ts | 21 + .../mutators/{comment.ts => Comment.ts} | 56 +- .../mutators/{settings.ts => Settings.ts} | 7 +- .../tenant/mutators/{story.ts => Story.ts} | 24 +- .../server/graph/tenant/mutators/index.ts | 8 +- ...th_integrations.ts => AuthIntegrations.ts} | 6 +- .../server/graph/tenant/resolvers/Comment.ts | 80 + .../{comment_counts.ts => CommentCounts.ts} | 6 +- .../resolvers/CommentModerationAction.ts | 24 + .../graph/tenant/resolvers/CommentRevision.ts | 18 + ...egration.ts => FacebookAuthIntegration.ts} | 4 +- ...ntegration.ts => GoogleAuthIntegration.ts} | 4 +- .../resolvers/{mutation.ts => Mutation.ts} | 12 +- ..._integration.ts => OIDCAuthIntegration.ts} | 4 +- .../resolvers/{profile.ts => Profile.ts} | 6 +- .../tenant/resolvers/{query.ts => Query.ts} | 4 +- .../server/graph/tenant/resolvers/Story.ts | 25 + .../server/graph/tenant/resolvers/User.ts | 8 + .../server/graph/tenant/resolvers/comment.ts | 91 - .../server/graph/tenant/resolvers/index.ts | 26 +- .../server/graph/tenant/resolvers/story.ts | 28 - .../server/graph/tenant/resolvers/user.ts | 8 - .../server/graph/tenant/schema/schema.graphql | 212 +- .../__snapshots__/comment.spec.ts.snap} | 0 .../comment.spec.ts} | 77 +- .../models/{action.ts => action/comment.ts} | 194 +- .../models/action/moderation/comment.ts | 173 ++ src/core/server/models/comment.ts | 362 ++- src/core/server/models/story.ts | 62 +- src/core/server/models/tenant.ts | 6 +- src/core/server/models/user.ts | 35 +- src/core/server/queue/Task.ts | 27 +- src/core/server/queue/tasks/Task.ts | 8 +- src/core/server/queue/tasks/mailer/index.ts | 101 +- src/core/server/queue/tasks/scraper/index.ts | 10 +- src/core/server/services/comments/actions.ts | 117 +- src/core/server/services/comments/index.ts | 45 +- .../comments/moderation/index.spec.ts | 13 +- .../services/comments/moderation/index.ts | 11 +- .../moderation/phases/commentLength.ts | 5 +- .../comments/moderation/phases/karma.ts | 5 +- .../comments/moderation/phases/links.ts | 5 +- .../comments/moderation/phases/spam.ts | 51 +- .../moderation/phases/storyClosed.spec.ts | 2 +- .../comments/moderation/phases/storyClosed.ts | 2 +- .../comments/moderation/phases/toxic.ts | 44 +- .../comments/moderation/phases/wordList.ts | 8 +- .../services/comments/moderation/wordList.ts | 5 +- src/core/server/services/moderation/index.ts | 81 + src/core/server/services/stories/index.ts | 76 +- .../server/services/tenant/cache/index.ts | 4 +- src/core/server/services/tenant/index.ts | 2 +- 66 files changed, 2323 insertions(+), 2224 deletions(-) create mode 100644 src/core/server/graph/tenant/loaders/Actions.ts rename src/core/server/graph/tenant/loaders/{auth.ts => Auth.ts} (100%) rename src/core/server/graph/tenant/loaders/{comments.ts => Comments.ts} (94%) rename src/core/server/graph/tenant/loaders/{stories.ts => Stories.ts} (100%) rename src/core/server/graph/tenant/loaders/{users.ts => Users.ts} (100%) create mode 100644 src/core/server/graph/tenant/mutators/Actions.ts rename src/core/server/graph/tenant/mutators/{comment.ts => Comment.ts} (54%) rename src/core/server/graph/tenant/mutators/{settings.ts => Settings.ts} (93%) rename src/core/server/graph/tenant/mutators/{story.ts => Story.ts} (59%) rename src/core/server/graph/tenant/resolvers/{auth_integrations.ts => AuthIntegrations.ts} (76%) create mode 100644 src/core/server/graph/tenant/resolvers/Comment.ts rename src/core/server/graph/tenant/resolvers/{comment_counts.ts => CommentCounts.ts} (77%) create mode 100644 src/core/server/graph/tenant/resolvers/CommentModerationAction.ts create mode 100644 src/core/server/graph/tenant/resolvers/CommentRevision.ts rename src/core/server/graph/tenant/resolvers/{facebook_auth_integration.ts => FacebookAuthIntegration.ts} (87%) rename src/core/server/graph/tenant/resolvers/{google_auth_integration.ts => GoogleAuthIntegration.ts} (88%) rename src/core/server/graph/tenant/resolvers/{mutation.ts => Mutation.ts} (91%) rename src/core/server/graph/tenant/resolvers/{oidc_auth_integration.ts => OIDCAuthIntegration.ts} (89%) rename src/core/server/graph/tenant/resolvers/{profile.ts => Profile.ts} (73%) rename src/core/server/graph/tenant/resolvers/{query.ts => Query.ts} (89%) create mode 100644 src/core/server/graph/tenant/resolvers/Story.ts create mode 100644 src/core/server/graph/tenant/resolvers/User.ts delete mode 100644 src/core/server/graph/tenant/resolvers/comment.ts delete mode 100644 src/core/server/graph/tenant/resolvers/story.ts delete mode 100644 src/core/server/graph/tenant/resolvers/user.ts rename src/core/server/models/{__snapshots__/action.spec.ts.snap => action/__snapshots__/comment.spec.ts.snap} (100%) rename src/core/server/models/{action.spec.ts => action/comment.spec.ts} (51%) rename src/core/server/models/{action.ts => action/comment.ts} (77%) create mode 100644 src/core/server/models/action/moderation/comment.ts create mode 100644 src/core/server/services/moderation/index.ts diff --git a/package-lock.json b/package-lock.json index 5973c0a88..b13ede9a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2082,9 +2082,9 @@ "dev": true }, "@types/lodash": { - "version": "4.14.112", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.112.tgz", - "integrity": "sha512-jDD7sendv3V7iwyRXSlECOR8HCtMN2faVA9ngLdHHihSVIwY7nbfsKl2kA6fimUDU1i5l/zgpG3aevwWnN3zCQ==", + "version": "4.14.118", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.118.tgz", + "integrity": "sha512-iiJbKLZbhSa6FYRip/9ZDX6HXhayXLDGY2Fqws9cOkEQ6XeKfaxB0sC541mowZJueYyMnVUmmG+al5/4fCDrgw==", "dev": true }, "@types/long": { @@ -2947,28 +2947,6 @@ "superagent": "^3.8.0" } }, - "align-text": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", - "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", - "dev": true, - "requires": { - "kind-of": "^3.0.2", - "longest": "^1.0.1", - "repeat-string": "^1.5.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "alphanum-sort": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", @@ -3255,12 +3233,12 @@ } }, "append-transform": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", - "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-0.4.0.tgz", + "integrity": "sha1-126/jKlNJ24keja61EpLdKthGZE=", "dev": true, "requires": { - "default-require-extensions": "^2.0.0" + "default-require-extensions": "^1.0.0" } }, "aproba": { @@ -5510,6 +5488,12 @@ } } }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true + }, "bach": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", @@ -6220,17 +6204,6 @@ "integrity": "sha512-Jt9tIBkRc9POUof7QA/VwWd+58fKkEEfI+/t1/eOlxKM7ZhrczNzMFefge7Ai+39y1pR/pP6cI19guHy3FSLmw==", "dev": true }, - "center-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", - "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", - "dev": true, - "optional": true, - "requires": { - "align-text": "^0.1.3", - "lazy-cache": "^1.0.3" - } - }, "chalk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", @@ -6860,12 +6833,6 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, - "compare-versions": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.3.0.tgz", - "integrity": "sha512-MAAAIOdi2s4Gl6rZ76PNcUa9IOYB+5ICdT41o5uMRf09aEu/F9RK+qhe8RjXNPwcTjGV7KU7h2P/fljThFVqyQ==", - "dev": true - }, "component-emitter": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", @@ -8114,12 +8081,23 @@ } }, "default-require-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", - "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz", + "integrity": "sha1-836hXT4T/9m0N9M+GnW1+5eHTLg=", "dev": true, "requires": { - "strip-bom": "^3.0.0" + "strip-bom": "^2.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + } } }, "default-resolution": { @@ -10001,13 +9979,10 @@ } }, "exec-sh": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.2.tgz", - "integrity": "sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==", - "dev": true, - "requires": { - "merge": "^1.2.0" - } + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.2.tgz", + "integrity": "sha512-9sLAvzhI5nc8TpuQUh4ahMdCrWT00wPWz7j47/emR5+2qEfoZP5zzUXvx+vdx+H6ohhnsYC31iX04QLYJK8zTg==", + "dev": true }, "execa": { "version": "0.7.0", @@ -10037,12 +10012,6 @@ } } }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true - }, "exit-hook": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", @@ -10501,9 +10470,9 @@ }, "dependencies": { "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "requires": { "fs.realpath": "^1.0.0", @@ -12341,91 +12310,22 @@ "dev": true }, "handlebars": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", - "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.12.tgz", + "integrity": "sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA==", "dev": true, "requires": { - "async": "^1.4.0", + "async": "^2.5.0", "optimist": "^0.6.1", - "source-map": "^0.4.4", - "uglify-js": "^2.6" + "source-map": "^0.6.1", + "uglify-js": "^3.1.4" }, "dependencies": { - "async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", - "dev": true - }, - "camelcase": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", - "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", - "dev": true, - "optional": true - }, - "cliui": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", - "dev": true, - "optional": true, - "requires": { - "center-align": "^0.1.1", - "right-align": "^0.1.1", - "wordwrap": "0.0.2" - } - }, "source-map": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", - "dev": true, - "requires": { - "amdefine": ">=0.0.4" - } - }, - "uglify-js": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", - "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", - "dev": true, - "optional": true, - "requires": { - "source-map": "~0.5.1", - "uglify-to-browserify": "~1.0.0", - "yargs": "~3.10.0" - }, - "dependencies": { - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true, - "optional": true - } - } - }, - "wordwrap": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", - "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", - "dev": true, - "optional": true - }, - "yargs": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", - "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", - "dev": true, - "optional": true, - "requires": { - "camelcase": "^1.0.2", - "cliui": "^2.1.0", - "decamelize": "^1.0.0", - "window-size": "0.1.0" - } + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true } } }, @@ -14033,26 +13933,6 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, - "istanbul-api": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-1.3.1.tgz", - "integrity": "sha512-duj6AlLcsWNwUpfyfHt0nWIeRiZpuShnP40YTxOGQgtaN8fd6JYSxsvxUphTDy8V5MfDXo4s/xVCIIvVCO808g==", - "dev": true, - "requires": { - "async": "^2.1.4", - "compare-versions": "^3.1.0", - "fileset": "^2.0.2", - "istanbul-lib-coverage": "^1.2.0", - "istanbul-lib-hook": "^1.2.0", - "istanbul-lib-instrument": "^1.10.1", - "istanbul-lib-report": "^1.1.4", - "istanbul-lib-source-maps": "^1.2.4", - "istanbul-reports": "^1.3.0", - "js-yaml": "^3.7.0", - "mkdirp": "^0.5.1", - "once": "^1.4.0" - } - }, "istanbul-lib-coverage": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.0.tgz", @@ -14060,12 +13940,12 @@ "dev": true }, "istanbul-lib-hook": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.2.1.tgz", - "integrity": "sha512-eLAMkPG9FU0v5L02lIkcj/2/Zlz9OuluaXikdr5iStk8FDbSwAixTK9TkYxbF0eNnzAJTwM2fkV2A1tpsIp4Jg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.2.2.tgz", + "integrity": "sha512-/Jmq7Y1VeHnZEQ3TL10VHyb564mn6VrQXHchON9Jf/AEcmQ3ZIiyD1BVzNOKTZf/G3gE+kiGK6SmpF9y3qGPLw==", "dev": true, "requires": { - "append-transform": "^1.0.0" + "append-transform": "^0.4.0" } }, "istanbul-lib-instrument": { @@ -14092,12 +13972,12 @@ } }, "istanbul-lib-report": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-1.1.4.tgz", - "integrity": "sha512-Azqvq5tT0U09nrncK3q82e/Zjkxa4tkFZv7E6VcqP0QCPn6oNljDPfrZEC/umNXds2t7b8sRJfs6Kmpzt8m2kA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-1.1.5.tgz", + "integrity": "sha512-UsYfRMoi6QO/doUshYNqcKJqVmFe9w51GZz8BS3WB0lYxAllQYklka2wP9+dGZeHYaWIdcXUx8JGdbqaoXRXzw==", "dev": true, "requires": { - "istanbul-lib-coverage": "^1.2.0", + "istanbul-lib-coverage": "^1.2.1", "mkdirp": "^0.5.1", "path-parse": "^1.0.5", "supports-color": "^3.1.2" @@ -14109,6 +13989,12 @@ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", "dev": true }, + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + }, "supports-color": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", @@ -14120,65 +14006,10 @@ } } }, - "istanbul-lib-source-maps": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.5.tgz", - "integrity": "sha512-8O2T/3VhrQHn0XcJbP1/GN7kXMiRAlPi+fj3uEHrjBD8Oz7Py0prSC25C09NuAZS6bgW1NNKAvCSHZXB0irSGA==", - "dev": true, - "requires": { - "debug": "^3.1.0", - "istanbul-lib-coverage": "^1.2.0", - "mkdirp": "^0.5.1", - "rimraf": "^2.6.1", - "source-map": "^0.5.3" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "rimraf": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", - "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", - "dev": true, - "requires": { - "glob": "^7.0.5" - } - } - } - }, "istanbul-reports": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-1.3.0.tgz", - "integrity": "sha512-y2Z2IMqE1gefWUaVjrBm0mSKvUkaBy9Vqz8iwr/r40Y9hBbIteH5wqHG/9DLTfJ9xUnUT2j7A3+VVJ6EaYBllA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-1.5.1.tgz", + "integrity": "sha512-+cfoZ0UXzWjhAdzosCPP3AN8vvef8XDkWtTfgaN+7L3YTpNYITnCaEkceo5SEYy644VkHka/P1FvkWvrG/rrJw==", "dev": true, "requires": { "handlebars": "^4.0.3" @@ -14262,22 +14093,6 @@ "source-map": "^0.5.7" } }, - "babel-jest": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.4.0.tgz", - "integrity": "sha1-IsNMOS4hdvakw2eZKn/P9p0uhVc=", - "dev": true, - "requires": { - "babel-plugin-istanbul": "^4.1.6", - "babel-preset-jest": "^23.2.0" - } - }, - "babylon": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", - "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", - "dev": true - }, "braces": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", @@ -14289,6 +14104,15 @@ "repeat-element": "^1.1.2" } }, + "exec-sh": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.2.tgz", + "integrity": "sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==", + "dev": true, + "requires": { + "merge": "^1.2.0" + } + }, "expand-brackets": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", @@ -14299,17 +14123,52 @@ } }, "expect": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-23.4.0.tgz", - "integrity": "sha1-baTsyZwUcSU+cogziYOtHrrbYMM=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-23.6.0.tgz", + "integrity": "sha512-dgSoOHgmtn/aDGRVFWclQyPDKl2CQRq0hmIEoUAuQs/2rn2NcvCWcSCovm6BLeuB/7EZuLGu2QfnR+qRt5OM4w==", "dev": true, "requires": { "ansi-styles": "^3.2.0", - "jest-diff": "^23.2.0", + "jest-diff": "^23.6.0", "jest-get-type": "^22.1.0", - "jest-matcher-utils": "^23.2.0", + "jest-matcher-utils": "^23.6.0", "jest-message-util": "^23.4.0", "jest-regex-util": "^23.3.0" + }, + "dependencies": { + "jest-diff": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-23.6.0.tgz", + "integrity": "sha512-Gz9l5Ov+X3aL5L37IT+8hoCUsof1CVYBb2QEkOupK64XyRR3h+uRpYIm97K7sY8diFxowR8pIGEdyfMKTixo3g==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "diff": "^3.2.0", + "jest-get-type": "^22.1.0", + "pretty-format": "^23.6.0" + } + }, + "jest-matcher-utils": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", + "integrity": "sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.1.0", + "pretty-format": "^23.6.0" + } + }, + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } } }, "extglob": { @@ -14322,9 +14181,9 @@ } }, "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "requires": { "fs.realpath": "^1.0.0", @@ -14335,6 +14194,59 @@ "path-is-absolute": "^1.0.0" } }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + }, + "dependencies": { + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, "is-extglob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", @@ -14351,9 +14263,9 @@ } }, "jest-cli": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-23.4.1.tgz", - "integrity": "sha512-Cmd7bex+kYmMGwGrIh/crwUieUFr+4PCTaK32tEA0dm0wklXV8zGgWh8n+8WbhsFPNzacolxdtcfBKIorcV5FQ==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-23.6.0.tgz", + "integrity": "sha512-hgeD1zRUp1E1zsiyOXjEn4LzRLWdJBV//ukAHGlx6s5mfCNJTbhbHjgxnDUXA8fsKWN/HqFFF6X5XcCwC/IvYQ==", "dev": true, "requires": { "ansi-escapes": "^3.0.0", @@ -14367,19 +14279,19 @@ "istanbul-lib-coverage": "^1.2.0", "istanbul-lib-instrument": "^1.10.1", "istanbul-lib-source-maps": "^1.2.4", - "jest-changed-files": "^23.4.0", - "jest-config": "^23.4.1", + "jest-changed-files": "^23.4.2", + "jest-config": "^23.6.0", "jest-environment-jsdom": "^23.4.0", "jest-get-type": "^22.1.0", - "jest-haste-map": "^23.4.1", + "jest-haste-map": "^23.6.0", "jest-message-util": "^23.4.0", "jest-regex-util": "^23.3.0", - "jest-resolve-dependencies": "^23.4.1", - "jest-runner": "^23.4.1", - "jest-runtime": "^23.4.1", - "jest-snapshot": "^23.4.1", + "jest-resolve-dependencies": "^23.6.0", + "jest-runner": "^23.6.0", + "jest-runtime": "^23.6.0", + "jest-snapshot": "^23.6.0", "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", + "jest-validate": "^23.6.0", "jest-watcher": "^23.4.0", "jest-worker": "^23.2.0", "micromatch": "^2.3.11", @@ -14392,37 +14304,520 @@ "strip-ansi": "^4.0.0", "which": "^1.2.12", "yargs": "^11.0.0" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "istanbul-api": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-1.3.7.tgz", + "integrity": "sha512-4/ApBnMVeEPG3EkSzcw25wDe4N66wxwn+KKn6b47vyek8Xb3NBAcg4xfuQbS7BqcZuTX4wxfD5lVagdggR3gyA==", + "dev": true, + "requires": { + "async": "^2.1.4", + "fileset": "^2.0.2", + "istanbul-lib-coverage": "^1.2.1", + "istanbul-lib-hook": "^1.2.2", + "istanbul-lib-instrument": "^1.10.2", + "istanbul-lib-report": "^1.1.5", + "istanbul-lib-source-maps": "^1.2.6", + "istanbul-reports": "^1.5.1", + "js-yaml": "^3.7.0", + "mkdirp": "^0.5.1", + "once": "^1.4.0" + }, + "dependencies": { + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.2.tgz", + "integrity": "sha512-aWHxfxDqvh/ZlxR8BBaEPVSWDPUkGD63VjGQn3jcw8jCp7sHEMKcrj4xfJn/ABzdMEHiQNyvDQhqm5o8+SQg7A==", + "dev": true, + "requires": { + "babel-generator": "^6.18.0", + "babel-template": "^6.16.0", + "babel-traverse": "^6.18.0", + "babel-types": "^6.18.0", + "babylon": "^6.18.0", + "istanbul-lib-coverage": "^1.2.1", + "semver": "^5.3.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.6.tgz", + "integrity": "sha512-TtbsY5GIHgbMsMiRw35YBHGpZ1DVFEO19vxxeiDMYaeOFOCzfnYVxvl6pOUIZR4dtPhAGpSMup8OyF8ubsaqEg==", + "dev": true, + "requires": { + "debug": "^3.1.0", + "istanbul-lib-coverage": "^1.2.1", + "mkdirp": "^0.5.1", + "rimraf": "^2.6.1", + "source-map": "^0.5.3" + }, + "dependencies": { + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + } + } + }, + "jest-changed-files": { + "version": "23.4.2", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-23.4.2.tgz", + "integrity": "sha512-EyNhTAUWEfwnK0Is/09LxoqNDOn7mU7S3EHskG52djOFS/z+IT0jT3h3Ql61+dklcG7bJJitIWEMB4Sp1piHmA==", + "dev": true, + "requires": { + "throat": "^4.0.0" + } + }, + "jest-haste-map": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-23.6.0.tgz", + "integrity": "sha512-uyNhMyl6dr6HaXGHp8VF7cK6KpC6G9z9LiMNsst+rJIZ8l7wY0tk8qwjPmEghczojZ2/ZhtEdIabZ0OQRJSGGg==", + "dev": true, + "requires": { + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.1.11", + "invariant": "^2.2.4", + "jest-docblock": "^23.2.0", + "jest-serializer": "^23.0.1", + "jest-worker": "^23.2.0", + "micromatch": "^2.3.11", + "sane": "^2.0.0" + }, + "dependencies": { + "sane": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/sane/-/sane-2.5.2.tgz", + "integrity": "sha1-tNwYYcIbQn6SlQej51HiosuKs/o=", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "capture-exit": "^1.2.0", + "exec-sh": "^0.2.0", + "fb-watchman": "^2.0.0", + "fsevents": "^1.2.3", + "micromatch": "^3.1.4", + "minimist": "^1.1.1", + "walker": "~1.0.5", + "watch": "~0.18.0" + }, + "dependencies": { + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + } + } + } + } + }, + "jest-resolve-dependencies": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-23.6.0.tgz", + "integrity": "sha512-EkQWkFWjGKwRtRyIwRwI6rtPAEyPWlUC2MpzHissYnzJeHcyCn1Hc8j7Nn1xUVrS5C6W5+ZL37XTem4D4pLZdA==", + "dev": true, + "requires": { + "jest-regex-util": "^23.3.0", + "jest-snapshot": "^23.6.0" + } + }, + "jest-runner": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-23.6.0.tgz", + "integrity": "sha512-kw0+uj710dzSJKU6ygri851CObtCD9cN8aNkg8jWJf4ewFyEa6kwmiH/r/M1Ec5IL/6VFa0wnAk6w+gzUtjJzA==", + "dev": true, + "requires": { + "exit": "^0.1.2", + "graceful-fs": "^4.1.11", + "jest-config": "^23.6.0", + "jest-docblock": "^23.2.0", + "jest-haste-map": "^23.6.0", + "jest-jasmine2": "^23.6.0", + "jest-leak-detector": "^23.6.0", + "jest-message-util": "^23.4.0", + "jest-runtime": "^23.6.0", + "jest-util": "^23.4.0", + "jest-worker": "^23.2.0", + "source-map-support": "^0.5.6", + "throat": "^4.0.0" + } + }, + "jest-runtime": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-23.6.0.tgz", + "integrity": "sha512-ycnLTNPT2Gv+TRhnAYAQ0B3SryEXhhRj1kA6hBPSeZaNQkJ7GbZsxOLUkwg6YmvWGdX3BB3PYKFLDQCAE1zNOw==", + "dev": true, + "requires": { + "babel-core": "^6.0.0", + "babel-plugin-istanbul": "^4.1.6", + "chalk": "^2.0.1", + "convert-source-map": "^1.4.0", + "exit": "^0.1.2", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.1.11", + "jest-config": "^23.6.0", + "jest-haste-map": "^23.6.0", + "jest-message-util": "^23.4.0", + "jest-regex-util": "^23.3.0", + "jest-resolve": "^23.6.0", + "jest-snapshot": "^23.6.0", + "jest-util": "^23.4.0", + "jest-validate": "^23.6.0", + "micromatch": "^2.3.11", + "realpath-native": "^1.0.0", + "slash": "^1.0.0", + "strip-bom": "3.0.0", + "write-file-atomic": "^2.1.0", + "yargs": "^11.0.0" + } + }, + "jest-watcher": { + "version": "23.4.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-23.4.0.tgz", + "integrity": "sha1-0uKM50+NrWxq/JIrksq+9u0FyRw=", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.1", + "string-length": "^2.0.0" + } + }, + "jest-worker": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-23.2.0.tgz", + "integrity": "sha1-+vcGqNo2+uYOsmlXJX+ntdjqArk=", + "dev": true, + "requires": { + "merge-stream": "^1.0.1" + } + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "node-notifier": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-5.3.0.tgz", + "integrity": "sha512-AhENzCSGZnZJgBARsUjnQ7DnZbzyP+HxlVXuD0xqAnvL8q+OqtSX7lGg9e8nHzwXkMMXNdVeqq4E2M3EUAqX6Q==", + "dev": true, + "requires": { + "growly": "^1.3.0", + "semver": "^5.5.0", + "shellwords": "^0.1.1", + "which": "^1.3.0" + } + }, + "prompts": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-0.1.14.tgz", + "integrity": "sha512-rxkyiE9YH6zAz/rZpywySLKkpaj0NMVyNw1qhsubdbjjSgcayjTShDreZGlFMcGSu5sab3bAKPfFk78PB90+8w==", + "dev": true, + "requires": { + "kleur": "^2.0.1", + "sisteransi": "^0.1.1" + } + }, + "string-length": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", + "integrity": "sha1-1A27aGo6zpYMHP/KVivyxF+DY+0=", + "dev": true, + "requires": { + "astral-regex": "^1.0.0", + "strip-ansi": "^4.0.0" + } + } } }, "jest-config": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-23.4.1.tgz", - "integrity": "sha512-OT29qlcw9Iw7u0PC04wD9tjLJL4vpGdMZrrHMFwYSO3HxOikbHywjmtQ7rntW4qvBcpbi7iCMTPPRmpDjImQEw==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-23.6.0.tgz", + "integrity": "sha512-i8V7z9BeDXab1+VNo78WM0AtWpBRXJLnkT+lyT+Slx/cbP5sZJ0+NDuLcmBE5hXAoK0aUp7vI+MOxR+R4d8SRQ==", "dev": true, "requires": { "babel-core": "^6.0.0", - "babel-jest": "^23.4.0", + "babel-jest": "^23.6.0", "chalk": "^2.0.1", "glob": "^7.1.1", "jest-environment-jsdom": "^23.4.0", "jest-environment-node": "^23.4.0", "jest-get-type": "^22.1.0", - "jest-jasmine2": "^23.4.1", + "jest-jasmine2": "^23.6.0", "jest-regex-util": "^23.3.0", - "jest-resolve": "^23.4.1", + "jest-resolve": "^23.6.0", "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", - "pretty-format": "^23.2.0" + "jest-validate": "^23.6.0", + "micromatch": "^2.3.11", + "pretty-format": "^23.6.0" + }, + "dependencies": { + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } + } + }, + "jest-docblock": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-23.2.0.tgz", + "integrity": "sha1-8IXh8YVI2Z/dabICB+b9VdkTg6c=", + "dev": true, + "requires": { + "detect-newline": "^2.1.0" } }, "jest-each": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-23.4.0.tgz", - "integrity": "sha1-L6nt2J2qGk7cn/m/YGKja3E0UUM=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-23.6.0.tgz", + "integrity": "sha512-x7V6M/WGJo6/kLoissORuvLIeAoyo2YqLOoCDkohgJ4XOXSqOtyvr8FbInlAWS77ojBsZrafbozWoKVRdtxFCg==", "dev": true, "requires": { "chalk": "^2.0.1", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" + }, + "dependencies": { + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } } }, "jest-environment-jsdom": { @@ -14447,22 +14842,58 @@ } }, "jest-jasmine2": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-23.4.1.tgz", - "integrity": "sha512-nHmRgTtM9fuaK3RBz2z4j9mYVEJwB7FdoflQSvrwHV8mCT5z4DeHoKCvPp2R27F8fZTYJUYVMb36xn+ydg0tfA==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-23.6.0.tgz", + "integrity": "sha512-pe2Ytgs1nyCs8IvsEJRiRTPC0eVYd8L/dXJGU08GFuBwZ4sYH/lmFDdOL3ZmvJR8QKqV9MFuwlsAi/EWkFUbsQ==", "dev": true, "requires": { + "babel-traverse": "^6.0.0", "chalk": "^2.0.1", "co": "^4.6.0", - "expect": "^23.4.0", + "expect": "^23.6.0", "is-generator-fn": "^1.0.0", - "jest-diff": "^23.2.0", - "jest-each": "^23.4.0", - "jest-matcher-utils": "^23.2.0", + "jest-diff": "^23.6.0", + "jest-each": "^23.6.0", + "jest-matcher-utils": "^23.6.0", "jest-message-util": "^23.4.0", - "jest-snapshot": "^23.4.1", + "jest-snapshot": "^23.6.0", "jest-util": "^23.4.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" + }, + "dependencies": { + "jest-diff": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-23.6.0.tgz", + "integrity": "sha512-Gz9l5Ov+X3aL5L37IT+8hoCUsof1CVYBb2QEkOupK64XyRR3h+uRpYIm97K7sY8diFxowR8pIGEdyfMKTixo3g==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "diff": "^3.2.0", + "jest-get-type": "^22.1.0", + "pretty-format": "^23.6.0" + } + }, + "jest-matcher-utils": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", + "integrity": "sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.1.0", + "pretty-format": "^23.6.0" + } + }, + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } } }, "jest-message-util": { @@ -14479,9 +14910,9 @@ } }, "jest-resolve": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-23.4.1.tgz", - "integrity": "sha512-VNk4YRNR5gsHhNS0Lp46/DzTT11e+ecbUC61ikE593cKbtdrhrMO+zXkOJaE8YDD5sHxH9W6OfssNn4FkZBzZQ==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-23.6.0.tgz", + "integrity": "sha512-XyoRxNtO7YGpQDmtQCmZjum1MljDqUCob7XlZ6jy9gsMugHdN2hY4+Acz9Qvjz2mSsOnPSH7skBmDYCHXVZqkA==", "dev": true, "requires": { "browser-resolve": "^1.11.3", @@ -14490,22 +14921,56 @@ } }, "jest-snapshot": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-23.4.1.tgz", - "integrity": "sha512-oMjaQ4vB4uT211zx00X0R7hg+oLVRDvhVKiC6+vSg7Be9S/AmkDMCVUoaPcLRK/0NkZBTzrh4WCzrSZgUEZW3g==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-23.6.0.tgz", + "integrity": "sha512-tM7/Bprftun6Cvj2Awh/ikS7zV3pVwjRYU2qNYS51VZHgaAMBs5l4o/69AiDHhQrj5+LA2Lq4VIvK7zYk/bswg==", "dev": true, "requires": { - "babel-traverse": "^6.0.0", "babel-types": "^6.0.0", "chalk": "^2.0.1", - "jest-diff": "^23.2.0", - "jest-matcher-utils": "^23.2.0", + "jest-diff": "^23.6.0", + "jest-matcher-utils": "^23.6.0", "jest-message-util": "^23.4.0", - "jest-resolve": "^23.4.1", + "jest-resolve": "^23.6.0", "mkdirp": "^0.5.1", "natural-compare": "^1.4.0", - "pretty-format": "^23.2.0", + "pretty-format": "^23.6.0", "semver": "^5.5.0" + }, + "dependencies": { + "jest-diff": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-23.6.0.tgz", + "integrity": "sha512-Gz9l5Ov+X3aL5L37IT+8hoCUsof1CVYBb2QEkOupK64XyRR3h+uRpYIm97K7sY8diFxowR8pIGEdyfMKTixo3g==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "diff": "^3.2.0", + "jest-get-type": "^22.1.0", + "pretty-format": "^23.6.0" + } + }, + "jest-matcher-utils": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", + "integrity": "sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.1.0", + "pretty-format": "^23.6.0" + } + }, + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } } }, "jest-util": { @@ -14533,15 +14998,27 @@ } }, "jest-validate": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-23.4.0.tgz", - "integrity": "sha1-2W7t4B7wOskJwAnpyORVGX1IwgE=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-23.6.0.tgz", + "integrity": "sha512-OFKapYxe72yz7agrDAWi8v2WL8GIfVqcbKRCLbRG9PAxtzF9b1SEDdTpytNDN12z2fJynoBwpMpvj2R39plI2A==", "dev": true, "requires": { "chalk": "^2.0.1", "jest-get-type": "^22.1.0", "leven": "^2.1.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" + }, + "dependencies": { + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } } }, "json5": { @@ -14600,15 +15077,6 @@ } } }, - "jest-changed-files": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-23.4.0.tgz", - "integrity": "sha1-8bME+YwjWvXZox7FJCYsXk3jxv8=", - "dev": true, - "requires": { - "throat": "^4.0.0" - } - }, "jest-config": { "version": "23.3.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-23.3.0.tgz", @@ -14740,121 +15208,6 @@ "integrity": "sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w==", "dev": true }, - "jest-haste-map": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-23.4.1.tgz", - "integrity": "sha512-PGQxOEGAfRbTyJkmZeOKkVSs+KVeWgG625p89KUuq+sIIchY5P8iPIIc+Hw2tJJPBzahU3qopw1kF/qyhDdNBw==", - "dev": true, - "requires": { - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.1.11", - "jest-docblock": "^23.2.0", - "jest-serializer": "^23.0.1", - "jest-worker": "^23.2.0", - "micromatch": "^2.3.11", - "sane": "^2.0.0" - }, - "dependencies": { - "arr-diff": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", - "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", - "dev": true, - "requires": { - "arr-flatten": "^1.0.1" - } - }, - "array-unique": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", - "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", - "dev": true - }, - "braces": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", - "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", - "dev": true, - "requires": { - "expand-range": "^1.8.1", - "preserve": "^0.2.0", - "repeat-element": "^1.1.2" - } - }, - "expand-brackets": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", - "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", - "dev": true, - "requires": { - "is-posix-bracket": "^0.1.0" - } - }, - "extglob": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", - "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", - "dev": true - }, - "is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "jest-docblock": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-23.2.0.tgz", - "integrity": "sha1-8IXh8YVI2Z/dabICB+b9VdkTg6c=", - "dev": true, - "requires": { - "detect-newline": "^2.1.0" - } - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - }, - "micromatch": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", - "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", - "dev": true, - "requires": { - "arr-diff": "^2.0.0", - "array-unique": "^0.2.1", - "braces": "^1.8.2", - "expand-brackets": "^0.1.4", - "extglob": "^0.3.1", - "filename-regex": "^2.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.1", - "kind-of": "^3.0.2", - "normalize-path": "^2.0.1", - "object.omit": "^2.0.0", - "parse-glob": "^3.0.4", - "regex-cache": "^0.4.2" - } - } - } - }, "jest-jasmine2": { "version": "23.3.0", "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-23.3.0.tgz", @@ -14904,12 +15257,39 @@ } }, "jest-leak-detector": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-23.2.0.tgz", - "integrity": "sha1-wonZYdxjjxQ1fU75bgQx7MGqN30=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-23.6.0.tgz", + "integrity": "sha512-f/8zA04rsl1Nzj10HIyEsXvYlMpMPcy0QkQilVZDFOaPbv2ur71X5u2+C4ZQJGyV/xvVXtCCZ3wQ99IgQxftCg==", "dev": true, "requires": { - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } } }, "jest-localstorage-mock": { @@ -14971,863 +15351,6 @@ "realpath-native": "^1.0.0" } }, - "jest-resolve-dependencies": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-23.4.1.tgz", - "integrity": "sha512-Jp0wgNJg3OYPvXJfNVX4k4/niwGS6ARuKacum/vue48+4A1XPJ2H3aVFuNb3gUaiB/6Le7Zyl8AUb4MELBfcmg==", - "dev": true, - "requires": { - "jest-regex-util": "^23.3.0", - "jest-snapshot": "^23.4.1" - }, - "dependencies": { - "arr-diff": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", - "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", - "dev": true, - "requires": { - "arr-flatten": "^1.0.1" - } - }, - "array-unique": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", - "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", - "dev": true - }, - "braces": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", - "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", - "dev": true, - "requires": { - "expand-range": "^1.8.1", - "preserve": "^0.2.0", - "repeat-element": "^1.1.2" - } - }, - "expand-brackets": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", - "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", - "dev": true, - "requires": { - "is-posix-bracket": "^0.1.0" - } - }, - "extglob": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", - "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", - "dev": true - }, - "is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "jest-message-util": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-23.4.0.tgz", - "integrity": "sha1-F2EMUJQjSVCNAaPR4L2iwHkIap8=", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0-beta.35", - "chalk": "^2.0.1", - "micromatch": "^2.3.11", - "slash": "^1.0.0", - "stack-utils": "^1.0.1" - } - }, - "jest-resolve": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-23.4.1.tgz", - "integrity": "sha512-VNk4YRNR5gsHhNS0Lp46/DzTT11e+ecbUC61ikE593cKbtdrhrMO+zXkOJaE8YDD5sHxH9W6OfssNn4FkZBzZQ==", - "dev": true, - "requires": { - "browser-resolve": "^1.11.3", - "chalk": "^2.0.1", - "realpath-native": "^1.0.0" - } - }, - "jest-snapshot": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-23.4.1.tgz", - "integrity": "sha512-oMjaQ4vB4uT211zx00X0R7hg+oLVRDvhVKiC6+vSg7Be9S/AmkDMCVUoaPcLRK/0NkZBTzrh4WCzrSZgUEZW3g==", - "dev": true, - "requires": { - "babel-traverse": "^6.0.0", - "babel-types": "^6.0.0", - "chalk": "^2.0.1", - "jest-diff": "^23.2.0", - "jest-matcher-utils": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-resolve": "^23.4.1", - "mkdirp": "^0.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^23.2.0", - "semver": "^5.5.0" - } - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - }, - "micromatch": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", - "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", - "dev": true, - "requires": { - "arr-diff": "^2.0.0", - "array-unique": "^0.2.1", - "braces": "^1.8.2", - "expand-brackets": "^0.1.4", - "extglob": "^0.3.1", - "filename-regex": "^2.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.1", - "kind-of": "^3.0.2", - "normalize-path": "^2.0.1", - "object.omit": "^2.0.0", - "parse-glob": "^3.0.4", - "regex-cache": "^0.4.2" - } - } - } - }, - "jest-runner": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-23.4.1.tgz", - "integrity": "sha512-78KyhObsx0VEuUQ74ikGt68NpP6PApTjGpJPSyZ7AvwOFRqFlxdHpCU/lFPQxW/fLEghl4irz9OHjRLGcGFNyQ==", - "dev": true, - "requires": { - "exit": "^0.1.2", - "graceful-fs": "^4.1.11", - "jest-config": "^23.4.1", - "jest-docblock": "^23.2.0", - "jest-haste-map": "^23.4.1", - "jest-jasmine2": "^23.4.1", - "jest-leak-detector": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-runtime": "^23.4.1", - "jest-util": "^23.4.0", - "jest-worker": "^23.2.0", - "source-map-support": "^0.5.6", - "throat": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "arr-diff": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", - "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", - "dev": true, - "requires": { - "arr-flatten": "^1.0.1" - } - }, - "array-unique": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", - "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", - "dev": true - }, - "babel-core": { - "version": "6.26.3", - "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", - "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", - "dev": true, - "requires": { - "babel-code-frame": "^6.26.0", - "babel-generator": "^6.26.0", - "babel-helpers": "^6.24.1", - "babel-messages": "^6.23.0", - "babel-register": "^6.26.0", - "babel-runtime": "^6.26.0", - "babel-template": "^6.26.0", - "babel-traverse": "^6.26.0", - "babel-types": "^6.26.0", - "babylon": "^6.18.0", - "convert-source-map": "^1.5.1", - "debug": "^2.6.9", - "json5": "^0.5.1", - "lodash": "^4.17.4", - "minimatch": "^3.0.4", - "path-is-absolute": "^1.0.1", - "private": "^0.1.8", - "slash": "^1.0.0", - "source-map": "^0.5.7" - } - }, - "babel-jest": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.4.0.tgz", - "integrity": "sha1-IsNMOS4hdvakw2eZKn/P9p0uhVc=", - "dev": true, - "requires": { - "babel-plugin-istanbul": "^4.1.6", - "babel-preset-jest": "^23.2.0" - } - }, - "babylon": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", - "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", - "dev": true - }, - "braces": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", - "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", - "dev": true, - "requires": { - "expand-range": "^1.8.1", - "preserve": "^0.2.0", - "repeat-element": "^1.1.2" - } - }, - "expand-brackets": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", - "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", - "dev": true, - "requires": { - "is-posix-bracket": "^0.1.0" - } - }, - "expect": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-23.4.0.tgz", - "integrity": "sha1-baTsyZwUcSU+cogziYOtHrrbYMM=", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "jest-diff": "^23.2.0", - "jest-get-type": "^22.1.0", - "jest-matcher-utils": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-regex-util": "^23.3.0" - } - }, - "extglob": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", - "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", - "dev": true - }, - "is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "jest-config": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-23.4.1.tgz", - "integrity": "sha512-OT29qlcw9Iw7u0PC04wD9tjLJL4vpGdMZrrHMFwYSO3HxOikbHywjmtQ7rntW4qvBcpbi7iCMTPPRmpDjImQEw==", - "dev": true, - "requires": { - "babel-core": "^6.0.0", - "babel-jest": "^23.4.0", - "chalk": "^2.0.1", - "glob": "^7.1.1", - "jest-environment-jsdom": "^23.4.0", - "jest-environment-node": "^23.4.0", - "jest-get-type": "^22.1.0", - "jest-jasmine2": "^23.4.1", - "jest-regex-util": "^23.3.0", - "jest-resolve": "^23.4.1", - "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", - "pretty-format": "^23.2.0" - } - }, - "jest-docblock": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-23.2.0.tgz", - "integrity": "sha1-8IXh8YVI2Z/dabICB+b9VdkTg6c=", - "dev": true, - "requires": { - "detect-newline": "^2.1.0" - } - }, - "jest-each": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-23.4.0.tgz", - "integrity": "sha1-L6nt2J2qGk7cn/m/YGKja3E0UUM=", - "dev": true, - "requires": { - "chalk": "^2.0.1", - "pretty-format": "^23.2.0" - } - }, - "jest-environment-jsdom": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-23.4.0.tgz", - "integrity": "sha1-BWp5UrP+pROsYqFAosNox52eYCM=", - "dev": true, - "requires": { - "jest-mock": "^23.2.0", - "jest-util": "^23.4.0", - "jsdom": "^11.5.1" - } - }, - "jest-environment-node": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-23.4.0.tgz", - "integrity": "sha1-V+gO0IQd6jAxZ8zozXlSHeuv3hA=", - "dev": true, - "requires": { - "jest-mock": "^23.2.0", - "jest-util": "^23.4.0" - } - }, - "jest-jasmine2": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-23.4.1.tgz", - "integrity": "sha512-nHmRgTtM9fuaK3RBz2z4j9mYVEJwB7FdoflQSvrwHV8mCT5z4DeHoKCvPp2R27F8fZTYJUYVMb36xn+ydg0tfA==", - "dev": true, - "requires": { - "chalk": "^2.0.1", - "co": "^4.6.0", - "expect": "^23.4.0", - "is-generator-fn": "^1.0.0", - "jest-diff": "^23.2.0", - "jest-each": "^23.4.0", - "jest-matcher-utils": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-snapshot": "^23.4.1", - "jest-util": "^23.4.0", - "pretty-format": "^23.2.0" - } - }, - "jest-message-util": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-23.4.0.tgz", - "integrity": "sha1-F2EMUJQjSVCNAaPR4L2iwHkIap8=", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0-beta.35", - "chalk": "^2.0.1", - "micromatch": "^2.3.11", - "slash": "^1.0.0", - "stack-utils": "^1.0.1" - } - }, - "jest-resolve": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-23.4.1.tgz", - "integrity": "sha512-VNk4YRNR5gsHhNS0Lp46/DzTT11e+ecbUC61ikE593cKbtdrhrMO+zXkOJaE8YDD5sHxH9W6OfssNn4FkZBzZQ==", - "dev": true, - "requires": { - "browser-resolve": "^1.11.3", - "chalk": "^2.0.1", - "realpath-native": "^1.0.0" - } - }, - "jest-snapshot": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-23.4.1.tgz", - "integrity": "sha512-oMjaQ4vB4uT211zx00X0R7hg+oLVRDvhVKiC6+vSg7Be9S/AmkDMCVUoaPcLRK/0NkZBTzrh4WCzrSZgUEZW3g==", - "dev": true, - "requires": { - "babel-traverse": "^6.0.0", - "babel-types": "^6.0.0", - "chalk": "^2.0.1", - "jest-diff": "^23.2.0", - "jest-matcher-utils": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-resolve": "^23.4.1", - "mkdirp": "^0.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^23.2.0", - "semver": "^5.5.0" - } - }, - "jest-util": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-23.4.0.tgz", - "integrity": "sha1-TQY8uSe68KI4Mf9hvsLLv0l5NWE=", - "dev": true, - "requires": { - "callsites": "^2.0.0", - "chalk": "^2.0.1", - "graceful-fs": "^4.1.11", - "is-ci": "^1.0.10", - "jest-message-util": "^23.4.0", - "mkdirp": "^0.5.1", - "slash": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "jest-validate": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-23.4.0.tgz", - "integrity": "sha1-2W7t4B7wOskJwAnpyORVGX1IwgE=", - "dev": true, - "requires": { - "chalk": "^2.0.1", - "jest-get-type": "^22.1.0", - "leven": "^2.1.0", - "pretty-format": "^23.2.0" - } - }, - "json5": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", - "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", - "dev": true - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - }, - "micromatch": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", - "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", - "dev": true, - "requires": { - "arr-diff": "^2.0.0", - "array-unique": "^0.2.1", - "braces": "^1.8.2", - "expand-brackets": "^0.1.4", - "extglob": "^0.3.1", - "filename-regex": "^2.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.1", - "kind-of": "^3.0.2", - "normalize-path": "^2.0.1", - "object.omit": "^2.0.0", - "parse-glob": "^3.0.4", - "regex-cache": "^0.4.2" - } - } - } - }, - "jest-runtime": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-23.4.1.tgz", - "integrity": "sha512-fnInrsEAbLpNctQa+RLnKZyQLMmb5u4YdoT9CbRKWhjMY7q6ledOu+x+ORZ3glQOK/vJIS701RaJRp1pc5ziaA==", - "dev": true, - "requires": { - "babel-core": "^6.0.0", - "babel-plugin-istanbul": "^4.1.6", - "chalk": "^2.0.1", - "convert-source-map": "^1.4.0", - "exit": "^0.1.2", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.1.11", - "jest-config": "^23.4.1", - "jest-haste-map": "^23.4.1", - "jest-message-util": "^23.4.0", - "jest-regex-util": "^23.3.0", - "jest-resolve": "^23.4.1", - "jest-snapshot": "^23.4.1", - "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", - "micromatch": "^2.3.11", - "realpath-native": "^1.0.0", - "slash": "^1.0.0", - "strip-bom": "3.0.0", - "write-file-atomic": "^2.1.0", - "yargs": "^11.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "arr-diff": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", - "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", - "dev": true, - "requires": { - "arr-flatten": "^1.0.1" - } - }, - "array-unique": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", - "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", - "dev": true - }, - "babel-core": { - "version": "6.26.3", - "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", - "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", - "dev": true, - "requires": { - "babel-code-frame": "^6.26.0", - "babel-generator": "^6.26.0", - "babel-helpers": "^6.24.1", - "babel-messages": "^6.23.0", - "babel-register": "^6.26.0", - "babel-runtime": "^6.26.0", - "babel-template": "^6.26.0", - "babel-traverse": "^6.26.0", - "babel-types": "^6.26.0", - "babylon": "^6.18.0", - "convert-source-map": "^1.5.1", - "debug": "^2.6.9", - "json5": "^0.5.1", - "lodash": "^4.17.4", - "minimatch": "^3.0.4", - "path-is-absolute": "^1.0.1", - "private": "^0.1.8", - "slash": "^1.0.0", - "source-map": "^0.5.7" - } - }, - "babel-jest": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.4.0.tgz", - "integrity": "sha1-IsNMOS4hdvakw2eZKn/P9p0uhVc=", - "dev": true, - "requires": { - "babel-plugin-istanbul": "^4.1.6", - "babel-preset-jest": "^23.2.0" - } - }, - "babylon": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", - "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", - "dev": true - }, - "braces": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", - "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", - "dev": true, - "requires": { - "expand-range": "^1.8.1", - "preserve": "^0.2.0", - "repeat-element": "^1.1.2" - } - }, - "expand-brackets": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", - "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", - "dev": true, - "requires": { - "is-posix-bracket": "^0.1.0" - } - }, - "expect": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-23.4.0.tgz", - "integrity": "sha1-baTsyZwUcSU+cogziYOtHrrbYMM=", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "jest-diff": "^23.2.0", - "jest-get-type": "^22.1.0", - "jest-matcher-utils": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-regex-util": "^23.3.0" - } - }, - "extglob": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", - "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", - "dev": true - }, - "is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", - "dev": true, - "requires": { - "is-extglob": "^1.0.0" - } - }, - "jest-config": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-23.4.1.tgz", - "integrity": "sha512-OT29qlcw9Iw7u0PC04wD9tjLJL4vpGdMZrrHMFwYSO3HxOikbHywjmtQ7rntW4qvBcpbi7iCMTPPRmpDjImQEw==", - "dev": true, - "requires": { - "babel-core": "^6.0.0", - "babel-jest": "^23.4.0", - "chalk": "^2.0.1", - "glob": "^7.1.1", - "jest-environment-jsdom": "^23.4.0", - "jest-environment-node": "^23.4.0", - "jest-get-type": "^22.1.0", - "jest-jasmine2": "^23.4.1", - "jest-regex-util": "^23.3.0", - "jest-resolve": "^23.4.1", - "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", - "pretty-format": "^23.2.0" - } - }, - "jest-each": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-23.4.0.tgz", - "integrity": "sha1-L6nt2J2qGk7cn/m/YGKja3E0UUM=", - "dev": true, - "requires": { - "chalk": "^2.0.1", - "pretty-format": "^23.2.0" - } - }, - "jest-environment-jsdom": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-23.4.0.tgz", - "integrity": "sha1-BWp5UrP+pROsYqFAosNox52eYCM=", - "dev": true, - "requires": { - "jest-mock": "^23.2.0", - "jest-util": "^23.4.0", - "jsdom": "^11.5.1" - } - }, - "jest-environment-node": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-23.4.0.tgz", - "integrity": "sha1-V+gO0IQd6jAxZ8zozXlSHeuv3hA=", - "dev": true, - "requires": { - "jest-mock": "^23.2.0", - "jest-util": "^23.4.0" - } - }, - "jest-jasmine2": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-23.4.1.tgz", - "integrity": "sha512-nHmRgTtM9fuaK3RBz2z4j9mYVEJwB7FdoflQSvrwHV8mCT5z4DeHoKCvPp2R27F8fZTYJUYVMb36xn+ydg0tfA==", - "dev": true, - "requires": { - "chalk": "^2.0.1", - "co": "^4.6.0", - "expect": "^23.4.0", - "is-generator-fn": "^1.0.0", - "jest-diff": "^23.2.0", - "jest-each": "^23.4.0", - "jest-matcher-utils": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-snapshot": "^23.4.1", - "jest-util": "^23.4.0", - "pretty-format": "^23.2.0" - } - }, - "jest-message-util": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-23.4.0.tgz", - "integrity": "sha1-F2EMUJQjSVCNAaPR4L2iwHkIap8=", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0-beta.35", - "chalk": "^2.0.1", - "micromatch": "^2.3.11", - "slash": "^1.0.0", - "stack-utils": "^1.0.1" - } - }, - "jest-resolve": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-23.4.1.tgz", - "integrity": "sha512-VNk4YRNR5gsHhNS0Lp46/DzTT11e+ecbUC61ikE593cKbtdrhrMO+zXkOJaE8YDD5sHxH9W6OfssNn4FkZBzZQ==", - "dev": true, - "requires": { - "browser-resolve": "^1.11.3", - "chalk": "^2.0.1", - "realpath-native": "^1.0.0" - } - }, - "jest-snapshot": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-23.4.1.tgz", - "integrity": "sha512-oMjaQ4vB4uT211zx00X0R7hg+oLVRDvhVKiC6+vSg7Be9S/AmkDMCVUoaPcLRK/0NkZBTzrh4WCzrSZgUEZW3g==", - "dev": true, - "requires": { - "babel-traverse": "^6.0.0", - "babel-types": "^6.0.0", - "chalk": "^2.0.1", - "jest-diff": "^23.2.0", - "jest-matcher-utils": "^23.2.0", - "jest-message-util": "^23.4.0", - "jest-resolve": "^23.4.1", - "mkdirp": "^0.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^23.2.0", - "semver": "^5.5.0" - } - }, - "jest-util": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-23.4.0.tgz", - "integrity": "sha1-TQY8uSe68KI4Mf9hvsLLv0l5NWE=", - "dev": true, - "requires": { - "callsites": "^2.0.0", - "chalk": "^2.0.1", - "graceful-fs": "^4.1.11", - "is-ci": "^1.0.10", - "jest-message-util": "^23.4.0", - "mkdirp": "^0.5.1", - "slash": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "jest-validate": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-23.4.0.tgz", - "integrity": "sha1-2W7t4B7wOskJwAnpyORVGX1IwgE=", - "dev": true, - "requires": { - "chalk": "^2.0.1", - "jest-get-type": "^22.1.0", - "leven": "^2.1.0", - "pretty-format": "^23.2.0" - } - }, - "json5": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", - "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", - "dev": true - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - }, - "micromatch": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", - "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", - "dev": true, - "requires": { - "arr-diff": "^2.0.0", - "array-unique": "^0.2.1", - "braces": "^1.8.2", - "expand-brackets": "^0.1.4", - "extglob": "^0.3.1", - "filename-regex": "^2.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.1", - "kind-of": "^3.0.2", - "normalize-path": "^2.0.1", - "object.omit": "^2.0.0", - "parse-glob": "^3.0.4", - "regex-cache": "^0.4.2" - } - } - } - }, "jest-serializer": { "version": "23.0.1", "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-23.0.1.tgz", @@ -15889,26 +15412,6 @@ "pretty-format": "^23.2.0" } }, - "jest-watcher": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-23.4.0.tgz", - "integrity": "sha1-0uKM50+NrWxq/JIrksq+9u0FyRw=", - "dev": true, - "requires": { - "ansi-escapes": "^3.0.0", - "chalk": "^2.0.1", - "string-length": "^2.0.0" - } - }, - "jest-worker": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-23.2.0.tgz", - "integrity": "sha1-+vcGqNo2+uYOsmlXJX+ntdjqArk=", - "dev": true, - "requires": { - "merge-stream": "^1.0.1" - } - }, "joi": { "version": "13.4.0", "resolved": "https://registry.npmjs.org/joi/-/joi-13.4.0.tgz", @@ -16178,9 +15681,9 @@ } }, "kleur": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-1.0.1.tgz", - "integrity": "sha512-8srIZ5BK5PCJw1L/JN741xgNfSjuQNK9ImYbYzv7ZUD3WPfuywaY+yd7lQOphJ+2vwXnMLnRZoAh5X+orRt4LQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-2.0.2.tgz", + "integrity": "sha512-77XF9iTllATmG9lSlIv0qdQ2BQ/h9t0bJllHlbvsQ0zUWfU7Yi0S8L5JXzPZgkefIiajLmBJJ4BsMJmqcf7oxQ==", "dev": true }, "koa": { @@ -16390,13 +15893,6 @@ "package-json": "^4.0.0" } }, - "lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", - "dev": true, - "optional": true - }, "lazystream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", @@ -17212,12 +16708,6 @@ "integrity": "sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s=", "dev": true }, - "longest": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true - }, "longest-streak": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-1.0.0.tgz", @@ -17543,9 +17033,8 @@ } }, "merge": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.0.tgz", - "integrity": "sha1-dTHjnUlJwoGma4xabgJl6LBYlNo=", + "version": "1.2.1", + "resolved": "", "dev": true }, "merge-descriptors": { @@ -18158,18 +17647,6 @@ } } }, - "node-notifier": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-5.2.1.tgz", - "integrity": "sha512-MIBs+AAd6dJ2SklbbE8RUDRlIVhU8MaNLh1A9SUZDUHPiZkWLFde6UNwG41yQHZEToHgJMXqyVZ9UcS/ReOVTg==", - "dev": true, - "requires": { - "growly": "^1.3.0", - "semver": "^5.4.1", - "shellwords": "^0.1.1", - "which": "^1.3.0" - } - }, "node-version": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/node-version/-/node-version-1.2.0.tgz", @@ -21589,16 +21066,6 @@ "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", "dev": true }, - "prompts": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-0.1.12.tgz", - "integrity": "sha512-pgR1GE1JM8q8UsHVIgjdK62DPwvrf0kvaKWJ/mfMoCm2lwfIReX/giQ1p0AlMoUXNhQap/8UiOdqi3bOROm/eg==", - "dev": true, - "requires": { - "kleur": "^1.0.0", - "sisteransi": "^0.1.1" - } - }, "prop-types": { "version": "15.6.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", @@ -23501,16 +22968,6 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" }, - "right-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", - "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", - "dev": true, - "optional": true, - "requires": { - "align-text": "^0.1.1" - } - }, "rimraf": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", @@ -23632,20 +23089,56 @@ "dev": true }, "sane": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/sane/-/sane-2.5.2.tgz", - "integrity": "sha1-tNwYYcIbQn6SlQej51HiosuKs/o=", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/sane/-/sane-4.0.2.tgz", + "integrity": "sha512-/3STCUfNSgMVpoREJc1i6ajKFlYZ5OflzZTOhlqPLa+01Ey+QR9iGZK7K5/qIRsQbEDCvqEJH/PL7yZywmnWsA==", "dev": true, "requires": { "anymatch": "^2.0.0", "capture-exit": "^1.2.0", - "exec-sh": "^0.2.0", + "exec-sh": "^0.3.2", + "execa": "^1.0.0", "fb-watchman": "^2.0.0", - "fsevents": "^1.2.3", "micromatch": "^3.1.4", "minimist": "^1.1.1", "walker": "~1.0.5", "watch": "~0.18.0" + }, + "dependencies": { + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } } }, "saslprep": { @@ -24464,33 +23957,6 @@ "integrity": "sha1-2sMECGkMIfPDYwo/86BYd73L1zY=", "dev": true }, - "string-length": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", - "integrity": "sha1-1A27aGo6zpYMHP/KVivyxF+DY+0=", - "dev": true, - "requires": { - "astral-regex": "^1.0.0", - "strip-ansi": "^4.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } - } - }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -25829,13 +25295,6 @@ } } }, - "uglify-to-browserify": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", - "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", - "dev": true, - "optional": true - }, "uglifyjs-webpack-plugin": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.2.7.tgz", @@ -26573,6 +26032,17 @@ "requires": { "exec-sh": "^0.2.0", "minimist": "^1.2.0" + }, + "dependencies": { + "exec-sh": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.2.tgz", + "integrity": "sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==", + "dev": true, + "requires": { + "merge": "^1.2.0" + } + } } }, "watchpack": { @@ -27226,13 +26696,6 @@ "string-width": "^2.1.1" } }, - "window-size": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", - "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", - "dev": true, - "optional": true - }, "winston": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.0.0.tgz", diff --git a/package.json b/package.json index dbf012e03..2377c6bf4 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "@types/jsdom": "^11.0.6", "@types/jsonwebtoken": "^7.2.7", "@types/linkify-it": "^2.0.3", - "@types/lodash": "^4.14.111", + "@types/lodash": "^4.14.118", "@types/luxon": "^0.5.3", "@types/mini-css-extract-plugin": "^0.2.0", "@types/mongodb": "^3.1.14", @@ -254,7 +254,7 @@ "relay-compiler-language-typescript": "^1.1.0", "relay-local-schema": "^0.7.0", "relay-runtime": "^1.7.0-rc.1", - "sane": "^2.5.2", + "sane": "^4.0.2", "simulant": "^0.2.2", "sinon": "^6.1.5", "style-loader": "^0.21.0", diff --git a/src/core/client/stream/tabs/comments/containers/ReactionButtonContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReactionButtonContainer.tsx index 43b4f0fce..9a0413a21 100644 --- a/src/core/client/stream/tabs/comments/containers/ReactionButtonContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReactionButtonContainer.tsx @@ -39,6 +39,7 @@ class ReactionButtonContainer extends React.Component< const input = { commentID: this.props.comment.id, + commentRevisionID: this.props.comment.revision.id, }; const { createCommentReaction, removeCommentReaction } = this.props; @@ -50,6 +51,7 @@ class ReactionButtonContainer extends React.Component< ? removeCommentReaction(input) : createCommentReaction(input); }; + public render() { const { actionCounts: { @@ -90,6 +92,9 @@ export default withShowAuthPopupMutation( comment: graphql` fragment ReactionButtonContainer_comment on Comment { id + revision { + id + } myActionPresence { reaction } diff --git a/src/core/client/stream/test/comments/editComment.spec.tsx b/src/core/client/stream/test/comments/editComment.spec.tsx index 1c75b4f22..a669501f0 100644 --- a/src/core/client/stream/test/comments/editComment.spec.tsx +++ b/src/core/client/stream/test/comments/editComment.spec.tsx @@ -106,7 +106,7 @@ it("cancel edit", async () => { .findByProps({ id: "comments-commentContainer-editButton-comment-0" }) .props.onClick(); - // Cacnel edit form. + // Cancel edit form. testRenderer.root .findByProps({ id: "comments-editCommentForm-cancelButton-comment-0" }) .props.onClick(); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index fff492da7..00c9b3136 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -52,6 +52,9 @@ export const users = [ export const baseComment = { author: users[0], body: "Comment Body", + revision: { + id: "revision-0", + }, createdAt: "2018-07-06T18:24:00.000Z", replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, replyCount: 0, diff --git a/src/core/server/app/middleware/passport/strategies/facebook.ts b/src/core/server/app/middleware/passport/strategies/facebook.ts index 54859d0b6..70442d54b 100644 --- a/src/core/server/app/middleware/passport/strategies/facebook.ts +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -71,7 +71,7 @@ export default class FacebookStrategy extends OAuth2Strategy< displayName, role: GQLUSER_ROLE.COMMENTER, email, - email_verified: emailVerified, + emailVerified, avatar, profiles: [profile], }); diff --git a/src/core/server/app/middleware/passport/strategies/google.ts b/src/core/server/app/middleware/passport/strategies/google.ts index 88fb4d7f7..0b6edc56e 100644 --- a/src/core/server/app/middleware/passport/strategies/google.ts +++ b/src/core/server/app/middleware/passport/strategies/google.ts @@ -84,7 +84,7 @@ export default class GoogleStrategy extends OAuth2Strategy< displayName, role: GQLUSER_ROLE.COMMENTER, email, - email_verified: emailVerified, + emailVerified, avatar, profiles: [profile], }); diff --git a/src/core/server/app/middleware/passport/strategies/oidc/index.ts b/src/core/server/app/middleware/passport/strategies/oidc/index.ts index 1d792f5f1..4f531478c 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc/index.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/index.ts @@ -185,7 +185,7 @@ export async function findOrCreateOIDCUser( displayName, role: GQLUSER_ROLE.COMMENTER, email, - email_verified, + emailVerified: email_verified, avatar: picture, profiles: [profile], }); diff --git a/src/core/server/graph/tenant/loaders/Actions.ts b/src/core/server/graph/tenant/loaders/Actions.ts new file mode 100644 index 000000000..85d483cad --- /dev/null +++ b/src/core/server/graph/tenant/loaders/Actions.ts @@ -0,0 +1,23 @@ +import TenantContext from "talk-server/graph/tenant/context"; +import { + CommentModerationActionFilter, + retrieveCommentModerationActionConnection, + retrieveCommentModerationActions, +} from "talk-server/models/action/moderation/comment"; +import { UserToCommentModerationActionHistoryArgs } from "../schema/__generated__/types"; + +export default (ctx: TenantContext) => ({ + commentModerationActions: (filter: CommentModerationActionFilter) => + retrieveCommentModerationActions(ctx.mongo, ctx.tenant.id, filter), + commentModerationActionsConnection: ( + { first = 10, after }: UserToCommentModerationActionHistoryArgs, + moderatorID: string + ) => + retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, { + first, + after, + filter: { + moderatorID, + }, + }), +}); diff --git a/src/core/server/graph/tenant/loaders/auth.ts b/src/core/server/graph/tenant/loaders/Auth.ts similarity index 100% rename from src/core/server/graph/tenant/loaders/auth.ts rename to src/core/server/graph/tenant/loaders/Auth.ts diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/Comments.ts similarity index 94% rename from src/core/server/graph/tenant/loaders/comments.ts rename to src/core/server/graph/tenant/loaders/Comments.ts index 61339bcb8..d3845bd24 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/Comments.ts @@ -8,10 +8,7 @@ import { GQLCOMMENT_SORT, StoryToCommentsArgs, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { - ACTION_ITEM_TYPE, - retrieveManyUserActionPresence, -} from "talk-server/models/action"; +import { retrieveManyUserActionPresence } from "talk-server/models/action/comment"; import { Comment, retrieveCommentParentsConnection, @@ -44,14 +41,13 @@ export default (ctx: Context) => ({ retrieveManyComments(ctx.mongo, ctx.tenant.id, ids) ), retrieveMyActionPresence: new DataLoader( - (itemIDs: string[]) => + (commentIDs: string[]) => retrieveManyUserActionPresence( 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 + commentIDs ) ), forUser: ( diff --git a/src/core/server/graph/tenant/loaders/stories.ts b/src/core/server/graph/tenant/loaders/Stories.ts similarity index 100% rename from src/core/server/graph/tenant/loaders/stories.ts rename to src/core/server/graph/tenant/loaders/Stories.ts diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/Users.ts similarity index 100% rename from src/core/server/graph/tenant/loaders/users.ts rename to src/core/server/graph/tenant/loaders/Users.ts diff --git a/src/core/server/graph/tenant/loaders/index.ts b/src/core/server/graph/tenant/loaders/index.ts index 671edf856..700a77e9c 100644 --- a/src/core/server/graph/tenant/loaders/index.ts +++ b/src/core/server/graph/tenant/loaders/index.ts @@ -1,12 +1,14 @@ import Context from "talk-server/graph/tenant/context"; -import Auth from "./auth"; -import Comments from "./comments"; -import Stories from "./stories"; -import Users from "./users"; +import Actions from "./Actions"; +import Auth from "./Auth"; +import Comments from "./Comments"; +import Stories from "./Stories"; +import Users from "./Users"; export default (ctx: Context) => ({ Auth: Auth(ctx), + Actions: Actions(ctx), Stories: Stories(ctx), Comments: Comments(ctx), Users: Users(ctx), diff --git a/src/core/server/graph/tenant/mutators/Actions.ts b/src/core/server/graph/tenant/mutators/Actions.ts new file mode 100644 index 000000000..f165f783f --- /dev/null +++ b/src/core/server/graph/tenant/mutators/Actions.ts @@ -0,0 +1,21 @@ +import TenantContext from "talk-server/graph/tenant/context"; +import { accept, reject } from "talk-server/services/moderation"; +import { + GQLAcceptCommentInput, + GQLRejectCommentInput, +} from "../schema/__generated__/types"; + +export const Actions = (ctx: TenantContext) => ({ + acceptComment: (input: GQLAcceptCommentInput) => + accept(ctx.mongo, ctx.tenant, { + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + moderatorID: ctx.user!.id, + }), + rejectComment: (input: GQLRejectCommentInput) => + reject(ctx.mongo, ctx.tenant, { + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + moderatorID: ctx.user!.id, + }), +}); diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/Comment.ts similarity index 54% rename from src/core/server/graph/tenant/mutators/comment.ts rename to src/core/server/graph/tenant/mutators/Comment.ts index e61377839..9c744babd 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/Comment.ts @@ -19,54 +19,62 @@ import { removeReaction, } from "talk-server/services/comments/actions"; -export default (ctx: TenantContext) => ({ - create: (input: GQLCreateCommentInput) => +export const Comment = (ctx: TenantContext) => ({ + create: ({ storyID, body, parentID }: GQLCreateCommentInput) => create( ctx.mongo, ctx.tenant, ctx.user!, - { - author_id: ctx.user!.id, - story_id: input.storyID, - body: input.body, - parent_id: input.parentID, - }, + { authorID: ctx.user!.id, storyID, body, parentID }, ctx.req ), - edit: (input: GQLEditCommentInput) => + edit: ({ commentID, body }: GQLEditCommentInput) => edit( ctx.mongo, ctx.tenant, ctx.user!, { - id: input.commentID, - body: input.body, + id: commentID, + body, }, ctx.req ), - createReaction: (input: GQLCreateCommentReactionInput) => + createReaction: ({ + commentID, + commentRevisionID, + }: GQLCreateCommentReactionInput) => createReaction(ctx.mongo, ctx.tenant, ctx.user!, { - item_id: input.commentID, + commentID, + commentRevisionID, }), - removeReaction: (input: GQLRemoveCommentReactionInput) => + removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) => removeReaction(ctx.mongo, ctx.tenant, ctx.user!, { - item_id: input.commentID, + commentID, }), - createDontAgree: (input: GQLCreateCommentDontAgreeInput) => + createDontAgree: ({ + commentID, + commentRevisionID, + }: GQLCreateCommentDontAgreeInput) => createDontAgree(ctx.mongo, ctx.tenant, ctx.user!, { - item_id: input.commentID, + commentID, + commentRevisionID, }), - removeDontAgree: (input: GQLRemoveCommentDontAgreeInput) => + removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) => removeDontAgree(ctx.mongo, ctx.tenant, ctx.user!, { - item_id: input.commentID, + commentID, }), - createFlag: (input: GQLCreateCommentFlagInput) => + createFlag: ({ + commentID, + commentRevisionID, + reason, + }: GQLCreateCommentFlagInput) => createFlag(ctx.mongo, ctx.tenant, ctx.user!, { - item_id: input.commentID, - reason: input.reason, + commentID, + commentRevisionID, + reason, }), - removeFlag: (input: GQLRemoveCommentFlagInput) => + removeFlag: ({ commentID }: GQLRemoveCommentFlagInput) => removeFlag(ctx.mongo, ctx.tenant, ctx.user!, { - item_id: input.commentID, + commentID, }), }); diff --git a/src/core/server/graph/tenant/mutators/settings.ts b/src/core/server/graph/tenant/mutators/Settings.ts similarity index 93% rename from src/core/server/graph/tenant/mutators/settings.ts rename to src/core/server/graph/tenant/mutators/Settings.ts index b7ffc1846..912d92361 100644 --- a/src/core/server/graph/tenant/mutators/settings.ts +++ b/src/core/server/graph/tenant/mutators/Settings.ts @@ -16,7 +16,12 @@ import { updateOIDCAuthIntegration, } from "talk-server/services/tenant"; -export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({ +export const Settings = ({ + mongo, + redis, + tenantCache, + tenant, +}: TenantContext) => ({ update: (input: GQLSettingsInput): Promise => update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)), regenerateSSOKey: (): Promise => diff --git a/src/core/server/graph/tenant/mutators/story.ts b/src/core/server/graph/tenant/mutators/Story.ts similarity index 59% rename from src/core/server/graph/tenant/mutators/story.ts rename to src/core/server/graph/tenant/mutators/Story.ts index b8909f832..014d5aa56 100644 --- a/src/core/server/graph/tenant/mutators/story.ts +++ b/src/core/server/graph/tenant/mutators/Story.ts @@ -8,12 +8,14 @@ import { GQLScrapeStoryInput, GQLUpdateStoryInput, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Story } from "talk-server/models/story"; +import * as story from "talk-server/models/story"; import { create, merge, remove, update } from "talk-server/services/stories"; import { scrape } from "talk-server/services/stories/scraper"; -export default (ctx: TenantContext) => ({ - create: async (input: GQLCreateStoryInput): Promise | null> => +export const Story = (ctx: TenantContext) => ({ + create: async ( + input: GQLCreateStoryInput + ): Promise | null> => create( ctx.mongo, ctx.tenant, @@ -21,12 +23,20 @@ export default (ctx: TenantContext) => ({ input.story.url, omitBy(input.story, isNull) ), - update: async (input: GQLUpdateStoryInput): Promise | null> => + update: async ( + input: GQLUpdateStoryInput + ): Promise | null> => update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)), - merge: async (input: GQLMergeStoriesInput): Promise | null> => + merge: async ( + input: GQLMergeStoriesInput + ): Promise | null> => merge(ctx.mongo, ctx.tenant, input.destinationID, input.sourceIDs), - remove: async (input: GQLRemoveStoryInput): Promise | null> => + remove: async ( + input: GQLRemoveStoryInput + ): Promise | null> => remove(ctx.mongo, ctx.tenant, input.id, input.includeComments), - scrape: async (input: GQLScrapeStoryInput): Promise | null> => + scrape: async ( + input: GQLScrapeStoryInput + ): Promise | null> => scrape(ctx.mongo, ctx.tenant.id, input.id), }); diff --git a/src/core/server/graph/tenant/mutators/index.ts b/src/core/server/graph/tenant/mutators/index.ts index fb4c940fd..f4df98af9 100644 --- a/src/core/server/graph/tenant/mutators/index.ts +++ b/src/core/server/graph/tenant/mutators/index.ts @@ -1,10 +1,12 @@ import TenantContext from "talk-server/graph/tenant/context"; -import Comment from "./comment"; -import Settings from "./settings"; -import Story from "./story"; +import { Actions } from "./Actions"; +import { Comment } from "./Comment"; +import { Settings } from "./Settings"; +import { Story } from "./Story"; export default (ctx: TenantContext) => ({ + Actions: Actions(ctx), Comment: Comment(ctx), Settings: Settings(ctx), Story: Story(ctx), diff --git a/src/core/server/graph/tenant/resolvers/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/AuthIntegrations.ts similarity index 76% rename from src/core/server/graph/tenant/resolvers/auth_integrations.ts rename to src/core/server/graph/tenant/resolvers/AuthIntegrations.ts index 1090b95a0..7625a7972 100644 --- a/src/core/server/graph/tenant/resolvers/auth_integrations.ts +++ b/src/core/server/graph/tenant/resolvers/AuthIntegrations.ts @@ -5,12 +5,12 @@ import { const disabled = { enabled: false }; -const AuthIntegrations: GQLAuthIntegrationsTypeResolver = { +export const AuthIntegrations: GQLAuthIntegrationsTypeResolver< + GQLAuthIntegrations +> = { local: auth => auth.local || disabled, sso: auth => auth.sso || disabled, oidc: auth => auth.oidc || disabled, google: auth => auth.google || disabled, facebook: auth => auth.facebook || disabled, }; - -export default AuthIntegrations; diff --git a/src/core/server/graph/tenant/resolvers/Comment.ts b/src/core/server/graph/tenant/resolvers/Comment.ts new file mode 100644 index 000000000..2c107f07e --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Comment.ts @@ -0,0 +1,80 @@ +import { GraphQLResolveInfo } from "graphql"; +import { getRequestedFields } from "talk-server/graph/tenant/resolvers/util"; +import { + GQLComment, + GQLCommentTypeResolver, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { decodeActionCounts } from "talk-server/models/action/comment"; +import * as comment from "talk-server/models/comment"; +import { getLatestRevision } from "talk-server/models/comment"; +import { createConnection } from "talk-server/models/connection"; + +import TenantContext from "../context"; + +const maybeLoadOnlyID = ( + ctx: TenantContext, + info: GraphQLResolveInfo, + id?: string +) => { + // If there isn't an id, then return nothing! + if (!id) { + return null; + } + + // Get the field names of the fields being requested, if it's only the ID, + // we have that, so no need to make a database request. + const fields = getRequestedFields(info); + if (fields.length === 1 && fields[0] === "id") { + return { + id, + }; + } + + // We want more than the ID! Get the comment! + // TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o. + return ctx.loaders.Comments.comment.load(id); +}; + +export const Comment: GQLCommentTypeResolver = { + body: c => getLatestRevision(c).body, + // Send the whole comment back when you request revisions. This way, we get to + // know the comment ID. The field mapping is handled by the CommentRevision + // resolver. + revision: c => ({ revision: getLatestRevision(c), comment: c }), + revisionHistory: c => c.revisions.map(revision => ({ revision, comment: c })), + editing: ({ revisions, createdAt }, input, ctx) => ({ + // When there is more than one body history, then the comment has been + // edited. + edited: revisions.length > 1, + // The date that the comment is editable until is the tenant's edit window + // length added to the comment created date. + editableUntil: new Date( + createdAt.valueOf() + ctx.tenant.editCommentWindowLength + ), + }), + author: (c, input, ctx) => ctx.loaders.Users.user.load(c.authorID), + statusHistory: ({ id }, input, ctx) => + ctx.loaders.Actions.commentModerationActions({ + commentID: id, + }), + replies: (c, input, ctx) => + c.replyCount > 0 + ? ctx.loaders.Comments.forParent(c.storyID, c.id, input) + : createConnection(), + actionCounts: c => decodeActionCounts(c.actionCounts), + myActionPresence: (c, input, ctx) => + ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null, + parentCount: c => (c.parentID ? c.grandparentIDs.length + 1 : 0), + depth: c => (c.parentID ? c.grandparentIDs.length + 1 : 0), + rootParent: (c, input, ctx, info) => + maybeLoadOnlyID( + ctx, + info, + c.grandparentIDs.length > 0 ? c.grandparentIDs[0] : c.parentID + ), + parent: (c, input, ctx, info) => maybeLoadOnlyID(ctx, info, c.parentID), + parents: (c, input, ctx) => + // Some resolver optimization. + c.parentID ? ctx.loaders.Comments.parents(c, input) : createConnection(), + story: (c, input, ctx) => ctx.loaders.Stories.story.load(c.storyID), +}; diff --git a/src/core/server/graph/tenant/resolvers/comment_counts.ts b/src/core/server/graph/tenant/resolvers/CommentCounts.ts similarity index 77% rename from src/core/server/graph/tenant/resolvers/comment_counts.ts rename to src/core/server/graph/tenant/resolvers/CommentCounts.ts index 2a643bb9c..c956a2b3a 100644 --- a/src/core/server/graph/tenant/resolvers/comment_counts.ts +++ b/src/core/server/graph/tenant/resolvers/CommentCounts.ts @@ -4,11 +4,11 @@ import { } from "talk-server/graph/tenant/schema/__generated__/types"; import { CommentStatusCounts } from "talk-server/models/story"; -const CommentCounts: GQLCommentCountsTypeResolver = { +export const CommentCounts: GQLCommentCountsTypeResolver< + CommentStatusCounts +> = { totalVisible: commentCounts => commentCounts[GQLCOMMENT_STATUS.ACCEPTED] + commentCounts[GQLCOMMENT_STATUS.NONE], statuses: commentCounts => commentCounts, }; - -export default CommentCounts; diff --git a/src/core/server/graph/tenant/resolvers/CommentModerationAction.ts b/src/core/server/graph/tenant/resolvers/CommentModerationAction.ts new file mode 100644 index 000000000..8bfc90234 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/CommentModerationAction.ts @@ -0,0 +1,24 @@ +import * as actions from "talk-server/models/action/moderation/comment"; +import { GQLCommentModerationActionTypeResolver } from "../schema/__generated__/types"; + +export const CommentModerationAction: GQLCommentModerationActionTypeResolver< + actions.CommentModerationAction +> = { + revision: async (action, input, ctx) => { + const comment = await ctx.loaders.Comments.comment.load(action.commentID); + if (!comment) { + return null; + } + + const revision = comment.revisions.find( + ({ id }) => id === action.commentRevisionID + ); + if (!revision) { + return null; + } + + return { comment, revision }; + }, + moderator: (action, input, ctx) => + action.moderatorID ? ctx.loaders.Users.user.load(action.moderatorID) : null, +}; diff --git a/src/core/server/graph/tenant/resolvers/CommentRevision.ts b/src/core/server/graph/tenant/resolvers/CommentRevision.ts new file mode 100644 index 000000000..716e4c02d --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/CommentRevision.ts @@ -0,0 +1,18 @@ +import { GQLCommentRevisionTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { decodeActionCounts } from "talk-server/models/action/comment"; +import * as comment from "talk-server/models/comment"; + +export interface WrappedCommentRevision { + revision: comment.Revision; + comment: comment.Comment; +} + +export const CommentRevision: Required< + GQLCommentRevisionTypeResolver +> = { + id: w => w.revision.id, + comment: w => w.comment, + actionCounts: w => decodeActionCounts(w.revision.actionCounts), + body: w => w.revision.body, + createdAt: w => w.revision.createdAt, +}; diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts similarity index 87% rename from src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts rename to src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts index 7b09a3c52..4fd05b8f0 100644 --- a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts @@ -4,7 +4,7 @@ import { GQLFacebookAuthIntegrationTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; -const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< +export const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< GQLFacebookAuthIntegration > = { callbackURL: (integration, args, ctx) => { @@ -22,5 +22,3 @@ const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< return constructTenantURL(ctx.config, ctx.tenant, path); }, }; - -export default FacebookAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts b/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts similarity index 88% rename from src/core/server/graph/tenant/resolvers/google_auth_integration.ts rename to src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts index b6293b562..879244131 100644 --- a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts @@ -4,7 +4,7 @@ import { GQLGoogleAuthIntegrationTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; -const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< +export const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< GQLGoogleAuthIntegration > = { callbackURL: (integration, args, ctx) => { @@ -22,5 +22,3 @@ const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< return constructTenantURL(ctx.config, ctx.tenant, path); }, }; - -export default GoogleAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts similarity index 91% rename from src/core/server/graph/tenant/resolvers/mutation.ts rename to src/core/server/graph/tenant/resolvers/Mutation.ts index 320da90dc..ec0303145 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -1,6 +1,6 @@ import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -const Mutation: GQLMutationTypeResolver = { +export const Mutation: GQLMutationTypeResolver = { editComment: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comment.edit(input), clientMutationId: input.clientMutationId, @@ -107,6 +107,12 @@ const Mutation: GQLMutationTypeResolver = { story: await ctx.mutators.Story.scrape(input), clientMutationId: input.clientMutationId, }), + acceptComment: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Actions.acceptComment(input), + clientMutationId: input.clientMutationId, + }), + rejectComment: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Actions.rejectComment(input), + clientMutationId: input.clientMutationId, + }), }; - -export default Mutation; diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts similarity index 89% rename from src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts rename to src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts index 3bd4aab70..bbf5b57b2 100644 --- a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts @@ -4,7 +4,7 @@ import { GQLOIDCAuthIntegrationTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; -const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< +export const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< GQLOIDCAuthIntegration > = { callbackURL: (integration, args, ctx) => { @@ -22,5 +22,3 @@ const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< return constructTenantURL(ctx.config, ctx.tenant, path); }, }; - -export default OIDCAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/profile.ts b/src/core/server/graph/tenant/resolvers/Profile.ts similarity index 73% rename from src/core/server/graph/tenant/resolvers/profile.ts rename to src/core/server/graph/tenant/resolvers/Profile.ts index c1c6ad13b..2101ecb80 100644 --- a/src/core/server/graph/tenant/resolvers/profile.ts +++ b/src/core/server/graph/tenant/resolvers/Profile.ts @@ -1,8 +1,8 @@ import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Profile } from "talk-server/models/user"; +import * as user from "talk-server/models/user"; -const resolveType: GQLProfileTypeResolver = profile => { +const resolveType: GQLProfileTypeResolver = profile => { switch (profile.type) { case "local": return "LocalProfile"; @@ -16,6 +16,6 @@ const resolveType: GQLProfileTypeResolver = profile => { } }; -export default { +export const Profile = { __resolveType: resolveType, }; diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/Query.ts similarity index 89% rename from src/core/server/graph/tenant/resolvers/query.ts rename to src/core/server/graph/tenant/resolvers/Query.ts index c217e9388..879fe2f43 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/Query.ts @@ -1,6 +1,6 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -const Query: GQLQueryTypeResolver = { +export const Query: GQLQueryTypeResolver = { story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate(args), comment: (source, { id }, ctx) => id ? ctx.loaders.Comments.comment.load(id) : null, @@ -11,5 +11,3 @@ const Query: GQLQueryTypeResolver = { debugScrapeStoryMetadata: (source, { url }, ctx) => ctx.loaders.Stories.debugScrapeMetadata.load(url), }; - -export default Query; diff --git a/src/core/server/graph/tenant/resolvers/Story.ts b/src/core/server/graph/tenant/resolvers/Story.ts new file mode 100644 index 000000000..b2996e2e1 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Story.ts @@ -0,0 +1,25 @@ +import { DateTime } from "luxon"; + +import { GQLStoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { decodeActionCounts } from "talk-server/models/action/comment"; +import * as story from "talk-server/models/story"; + +export const Story: GQLStoryTypeResolver = { + comments: (s, input, ctx) => ctx.loaders.Comments.forStory(s.id, input), + isClosed: () => false, + closedAt: (s, input, ctx) => { + if (s.closedAt) { + return s.closedAt; + } + + if (ctx.tenant.autoCloseStream && ctx.tenant.closedTimeout) { + return DateTime.fromJSDate(s.createdAt) + .plus(ctx.tenant.closedTimeout) + .toJSDate(); + } + + return null; + }, + commentActionCounts: s => decodeActionCounts(s.commentActionCounts), + commentCounts: s => s.commentCounts, +}; diff --git a/src/core/server/graph/tenant/resolvers/User.ts b/src/core/server/graph/tenant/resolvers/User.ts new file mode 100644 index 000000000..6e4919286 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/User.ts @@ -0,0 +1,8 @@ +import { GQLUserTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import * as user from "talk-server/models/user"; + +export const User: GQLUserTypeResolver = { + comments: ({ id }, input, ctx) => ctx.loaders.Comments.forUser(id, input), + commentModerationActionHistory: ({ id }, input, ctx) => + ctx.loaders.Actions.commentModerationActionsConnection(input, id), +}; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts deleted file mode 100644 index d9b2717cd..000000000 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getRequestedFields } from "talk-server/graph/tenant/resolvers/util"; -import { - GQLComment, - GQLCommentTypeResolver, -} from "talk-server/graph/tenant/schema/__generated__/types"; -import { decodeActionCounts } from "talk-server/models/action"; -import { Comment } from "talk-server/models/comment"; -import { createConnection } from "talk-server/models/connection"; - -const Comment: GQLCommentTypeResolver = { - editing: (comment, input, ctx) => ({ - // When there is more than one body history, then the comment has been - // edited. - edited: comment.body_history.length > 1, - // The date that the comment is editable until is the tenant's edit window - // length added to the comment created date. - editableUntil: new Date( - comment.created_at.valueOf() + ctx.tenant.editCommentWindowLength - ), - }), - createdAt: comment => comment.created_at, - author: (comment, input, ctx) => - ctx.loaders.Users.user.load(comment.author_id), - replies: (comment, input, ctx) => - comment.reply_count > 0 - ? ctx.loaders.Comments.forParent(comment.story_id, comment.id, input) - : createConnection(), - actionCounts: comment => decodeActionCounts(comment.action_counts), - myActionPresence: (comment, input, ctx) => - ctx.user - ? ctx.loaders.Comments.retrieveMyActionPresence.load(comment.id) - : null, - parentCount: comment => - comment.parent_id ? comment.grandparent_ids.length + 1 : 0, - depth: comment => - comment.parent_id ? comment.grandparent_ids.length + 1 : 0, - replyCount: comment => comment.reply_count, - rootParent: (comment, input, ctx, info) => { - // If there isn't a parent, then return nothing! - if (!comment.parent_id) { - return null; - } - - // rootParentID is the root parent id for a given comment. - const rootParentID = - comment.grandparent_ids.length > 0 - ? comment.grandparent_ids[0] - : comment.parent_id; - - // Get the field names of the fields being requested, if it's only the ID, - // we have that, so no need to make a database request. - const fields = getRequestedFields(info); - if (fields.length === 1 && fields[0] === "id") { - return { - id: rootParentID, - }; - } - - // We want more than the ID! Get the comment! - // TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o. - return ctx.loaders.Comments.comment.load(rootParentID); - }, - parent: (comment, input, ctx, info) => { - // If there isn't a parent, then return nothing! - if (!comment.parent_id) { - return null; - } - - // Get the field names of the fields being requested, if it's only the ID, - // we have that, so no need to make a database request. - const fields = getRequestedFields(info); - if (fields.length === 1 && fields[0] === "id") { - return { - id: comment.parent_id, - }; - } - - // We want more than the ID! Get the comment! - // TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o. - return ctx.loaders.Comments.comment.load(comment.parent_id); - }, - parents: (comment, input, ctx) => - // Some resolver optimization. - comment.parent_id - ? ctx.loaders.Comments.parents(comment, input) - : createConnection(), - story: (comment, input, ctx) => - ctx.loaders.Stories.story.load(comment.story_id), -}; - -export default Comment; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index add6ed2c9..50266a9be 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -3,22 +3,26 @@ import Time from "talk-server/graph/common/scalars/time"; import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import AuthIntegrations from "./auth_integrations"; -import Comment from "./comment"; -import CommentCounts from "./comment_counts"; -import FacebookAuthIntegration from "./facebook_auth_integration"; -import GoogleAuthIntegration from "./google_auth_integration"; -import Mutation from "./mutation"; -import OIDCAuthIntegration from "./oidc_auth_integration"; -import Profile from "./profile"; -import Query from "./query"; -import Story from "./story"; -import User from "./user"; +import { AuthIntegrations } from "./AuthIntegrations"; +import { Comment } from "./Comment"; +import { CommentCounts } from "./CommentCounts"; +import { CommentModerationAction } from "./CommentModerationAction"; +import { CommentRevision } from "./CommentRevision"; +import { FacebookAuthIntegration } from "./FacebookAuthIntegration"; +import { GoogleAuthIntegration } from "./GoogleAuthIntegration"; +import { Mutation } from "./Mutation"; +import { OIDCAuthIntegration } from "./OIDCAuthIntegration"; +import { Profile } from "./Profile"; +import { Query } from "./Query"; +import { Story } from "./Story"; +import { User } from "./User"; const Resolvers: GQLResolver = { AuthIntegrations, Comment, CommentCounts, + CommentModerationAction, + CommentRevision, Cursor, Mutation, OIDCAuthIntegration, diff --git a/src/core/server/graph/tenant/resolvers/story.ts b/src/core/server/graph/tenant/resolvers/story.ts deleted file mode 100644 index 85a38dec3..000000000 --- a/src/core/server/graph/tenant/resolvers/story.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { DateTime } from "luxon"; - -import { GQLStoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { decodeActionCounts } from "talk-server/models/action"; -import { Story } from "talk-server/models/story"; - -const Story: GQLStoryTypeResolver = { - comments: (story, input, ctx) => - ctx.loaders.Comments.forStory(story.id, input), - isClosed: () => false, - closedAt: (story, input, ctx) => { - if (story.closedAt) { - return story.closedAt; - } - - if (ctx.tenant.autoCloseStream && ctx.tenant.closedTimeout) { - return DateTime.fromJSDate(story.created_at) - .plus(ctx.tenant.closedTimeout) - .toJSDate(); - } - - return null; - }, - actionCounts: story => decodeActionCounts(story.action_counts), - commentCounts: story => story.comment_counts, -}; - -export default Story; diff --git a/src/core/server/graph/tenant/resolvers/user.ts b/src/core/server/graph/tenant/resolvers/user.ts deleted file mode 100644 index d935b7970..000000000 --- a/src/core/server/graph/tenant/resolvers/user.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { GQLUserTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { User } from "talk-server/models/user"; - -const User: GQLUserTypeResolver = { - comments: (user, input, ctx) => ctx.loaders.Comments.forUser(user.id, input), -}; - -export default User; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 38f7d6fee..94797b28f 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -975,6 +975,15 @@ type User { orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection! @auth(roles: [ADMIN, MODERATOR], userIDField: "id") + + """ + commentModerationActionHistory returns a CommentModerationActionConnection that this User has + created. + """ + commentModerationActionHistory( + first: Int = 10 + after: Cursor + ): CommentModerationActionConnection! @auth(role: [MODERATOR, ADMIN]) } ################################################################################ @@ -1024,6 +1033,85 @@ enum COMMENT_STATUS { SYSTEM_WITHHELD } +type CommentModerationAction { + id: ID! + + """ + revision is the moderated CommentRevision. + """ + revision: CommentRevision! + + """ + status represents the status that was assigned by the moderator. + """ + status: COMMENT_STATUS! + + """ + moderator is the User that performed the Moderator action. If null, this means + that the system has assigned the moderation status. + """ + moderator: User + + """ + createdAt is the time that the CommentModerationAction was created. + """ + createdAt: Time! +} + +type CommentModerationActionEdge { + """ + node is the CommentModerationAction for this edge. + """ + node: CommentModerationAction! + + """ + + """ + cursor: Cursor +} + +type CommentModerationActionConnection { + """ + edges are a subset of CommentModerationActionEdge's. + """ + edges: [CommentModerationActionEdge!]! + + """ + pageInfo is + """ + pageInfo: PageInfo! +} + +type CommentRevision { + """ + id is the identifier of the CommentRevision. + """ + id: ID! + + """ + comment is the reference to the original Comment associated with the current + Comment. + """ + comment: Comment! + + """ + actionCounts stores the counts of all the actions for the CommentRevision + specifically. + """ + actionCounts: ActionCounts! @auth(roles: [MODERATOR, ADMIN]) + + """ + body is the content of the CommentRevision. If null, it indicates that the + body text was deleted. + """ + body: String + + """ + createdAt is the time that the CommentRevision was created. + """ + createdAt: Time! +} + """ Comment is a comment left by a User on an Story or another Comment as a reply. """ @@ -1034,10 +1122,23 @@ type Comment { id: ID! """ - body is the content of the Comment. + body is the content of the Comment, and is an alias to the body of the + `currentRevision.body`. """ body: String + """ + revision is the current revision of the Comment's body. + """ + revision: CommentRevision! + + """ + revisionHistory stores the previous CommentRevision's, with the most recent + edit last. + """ + revisionHistory: [CommentRevision!]! + @auth(roles: [MODERATOR, ADMIN], userIDField: "author_id") + """ createdAt is the date in which the Comment was created. """ @@ -1053,6 +1154,13 @@ type Comment { """ status: COMMENT_STATUS! + """ + statusHistory returns a CommentModerationActionConnection that will list + the history of moderator actions performed on the Comment, with the most + recent last. + """ + statusHistory: [CommentModerationAction!]! @auth(role: [MODERATOR, ADMIN]) + """ parentCount is the number of direct parents for this Comment. Currently this value is the same as depth. @@ -1107,7 +1215,7 @@ type Comment { actionCounts: ActionCounts! """ - myActionPresence stores the presense information for all the actions + myActionPresence stores the presence information for all the actions left by the current User on this Comment. """ myActionPresence: ActionPresence @@ -1302,10 +1410,10 @@ type Story { ): CommentsConnection! """ - actionCounts stores the counts of all the actions against this Story and it's + commentActionCounts stores the counts of all the actions against this Story and it's Comments. """ - actionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR]) + commentActionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR]) """ closedAt is the Time that the Story is closed for commenting. @@ -1927,6 +2035,12 @@ input CreateCommentReactionInput { """ commentID: ID! + """ + commentRevisionID is the revision ID of the Comment that we're creating the + Reaction on. + """ + commentRevisionID: ID! + """ clientMutationId is required for Relay support. """ @@ -1983,6 +2097,12 @@ input CreateCommentDontAgreeInput { """ commentID: ID! + """ + commentRevisionID is the revision ID of the Comment that we're creating the + DontAgree on. + """ + commentRevisionID: ID! + """ clientMutationId is required for Relay support. """ @@ -2039,6 +2159,12 @@ input CreateCommentFlagInput { """ commentID: ID! + """ + commentRevisionID is the revision ID of the Comment that we're creating the + Flag on. + """ + commentRevisionID: ID! + """ reason is the selected reason why the Flag is being created. """ @@ -2603,6 +2729,72 @@ type ScrapeStoryPayload { clientMutationId: String! } +################## +# acceptComment +################## + +input AcceptCommentInput { + """ + commentID is the ID of the Comment that was accepted. + """ + commentID: ID! + + """ + commentRevisionID is the ID of the CommentRevision that is being accepted. + """ + commentRevisionID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type AcceptCommentPayload { + """ + comment is the Comment that was accepted. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# rejectComment +################## + +input RejectCommentInput { + """ + commentID is the ID of the Comment that was rejected. + """ + commentID: ID! + + """ + commentRevisionID is the ID of the CommentRevision that is being rejected. + """ + commentRevisionID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type RejectCommentPayload { + """ + comment is the Comment that was rejected. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -2730,6 +2922,18 @@ type Mutation { """ scrapeStory(input: ScrapeStoryInput!): ScrapeStoryPayload @auth(roles: [ADMIN, MODERATOR]) + + """ + acceptComment will mark the Comment as ACCEPTED. + """ + acceptComment(input: AcceptCommentInput!): AcceptCommentPayload + @auth(roles: [MODERATOR, ADMIN]) + + """ + rejectComment will mark the Comment as REJECTED. + """ + rejectComment(input: RejectCommentInput!): RejectCommentPayload + @auth(roles: [MODERATOR, ADMIN]) } ################################################################################ diff --git a/src/core/server/models/__snapshots__/action.spec.ts.snap b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap similarity index 100% rename from src/core/server/models/__snapshots__/action.spec.ts.snap rename to src/core/server/models/action/__snapshots__/comment.spec.ts.snap diff --git a/src/core/server/models/action.spec.ts b/src/core/server/models/action/comment.spec.ts similarity index 51% rename from src/core/server/models/action.spec.ts rename to src/core/server/models/action/comment.spec.ts index f89f587eb..40c1cf837 100644 --- a/src/core/server/models/action.spec.ts +++ b/src/core/server/models/action/comment.spec.ts @@ -1,27 +1,26 @@ import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__generated__/types"; import { - Action, - ACTION_ITEM_TYPE, ACTION_TYPE, + CommentAction, decodeActionCounts, encodeActionCounts, validateAction, -} from "talk-server/models/action"; +} from "talk-server/models/action/comment"; describe("#encodeActionCounts", () => { it("generates the action counts correctly", () => { - const actions = [ - { action_type: ACTION_TYPE.DONT_AGREE }, + const actions: Array> = [ + { actionType: ACTION_TYPE.DONT_AGREE }, { - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, }, { - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, }, ]; - const actionCounts = encodeActionCounts(...(actions as Action[])); + const actionCounts = encodeActionCounts(...(actions as CommentAction[])); expect(actionCounts).toMatchSnapshot(); }); @@ -29,22 +28,24 @@ describe("#encodeActionCounts", () => { describe("#decodeActionCounts", () => { it("parses the action counts correctly", () => { - const actions = [ - { action_type: ACTION_TYPE.REACTION }, - { action_type: ACTION_TYPE.REACTION }, - { action_type: ACTION_TYPE.REACTION }, - { action_type: ACTION_TYPE.DONT_AGREE }, + const actions: Array> = [ + { actionType: ACTION_TYPE.REACTION }, + { actionType: ACTION_TYPE.REACTION }, + { actionType: ACTION_TYPE.REACTION }, + { actionType: ACTION_TYPE.DONT_AGREE }, { - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, }, { - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, }, ]; - const modelActionCounts = encodeActionCounts(...(actions as Action[])); + const modelActionCounts = encodeActionCounts( + ...(actions as CommentAction[]) + ); expect(modelActionCounts).toMatchSnapshot(); @@ -56,77 +57,65 @@ describe("#decodeActionCounts", () => { describe("#validateAction", () => { it("allows a valid action", () => { - const actions = [ + const actions: Array> = [ { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.REACTION, + actionType: ACTION_TYPE.REACTION, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.DONT_AGREE, + actionType: ACTION_TYPE.DONT_AGREE, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, }, ]; for (const action of actions) { - validateAction(action as Action); + validateAction(action as CommentAction); } }); it("does not allow an invalid action", () => { - const actions = [ + const actions: Array> = [ { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.DONT_AGREE, + actionType: ACTION_TYPE.DONT_AGREE, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.DONT_AGREE, + actionType: ACTION_TYPE.DONT_AGREE, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, }, { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, }, ]; for (const action of actions) { - expect(() => validateAction(action as Action)).toThrow(); + expect(() => validateAction(action as CommentAction)).toThrow(); } }); }); diff --git a/src/core/server/models/action.ts b/src/core/server/models/action/comment.ts similarity index 77% rename from src/core/server/models/action.ts rename to src/core/server/models/action/comment.ts index 6705bdb22..98926b4b2 100644 --- a/src/core/server/models/action.ts +++ b/src/core/server/models/action/comment.ts @@ -15,7 +15,7 @@ import { FilterQuery } from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; function collection(db: Db) { - return db.collection>("actions"); + return db.collection>("commentActions"); } export enum ACTION_TYPE { @@ -37,15 +37,7 @@ export enum ACTION_TYPE { FLAG = "FLAG", } -export type EncodedActionCounts = Record; - -export interface ActionCountGroup { - total: number; -} - -export enum ACTION_ITEM_TYPE { - COMMENTS = "COMMENTS", -} +export type EncodedCommentActionCounts = Record; /** * FLAG_REASON is the reason that a given Flag has been created. @@ -55,27 +47,27 @@ export type FLAG_REASON = | GQLCOMMENT_FLAG_REPORTED_REASON | GQLCOMMENT_FLAG_REASON; -export interface Action extends TenantResource { +export interface CommentAction extends TenantResource { /** * id is the identifier for this specific Action. */ readonly id: string; /** - * action_type is the type of Action that this represents. + * actionType is the type of Action that this represents. */ - action_type: ACTION_TYPE; + actionType: ACTION_TYPE; /** - * item_type enables polymorphic behavior be allowing multiple item types - * to be represented in a single collection. + * commentID is the ID of the specific item that this Action is associated with. */ - item_type: ACTION_ITEM_TYPE; + commentID: string; /** - * item_id is the ID of the specific item that this Action is associated with. + * commentRevisionID is the ID of the specific comment text that the Action + * is relating to. */ - item_id: string; + commentRevisionID: string; /** * reason is the reason or secondary grouping identifier for why this @@ -84,22 +76,22 @@ export interface Action extends TenantResource { reason?: FLAG_REASON; /** - * root_item_id represents the identifier for the item's associated item. In + * storyID represents the identifier for the item's associated item. In * the case of a REACTION left on a Comment, this ID would be the Stories ID. * In the case of a FLAG left on a User, this ID would be null. */ - root_item_id?: string; + storyID?: string; /** - * user_id is the ID of the User that left this Action. In the event that the + * userID is the ID of the User that left this Action. In the event that the * Action was left by Talk, it will be null. */ - user_id?: string; + userID: string | null; /** - * created_at is the date that this particular Action was created at. + * createdAt is the date that this particular Action was created at. */ - created_at: Date; + createdAt: Date; /** * metadata is arbitrary information stored for this Action. @@ -110,21 +102,18 @@ export interface Action extends TenantResource { const ActionSchema = [ // Flags { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, // Only reasons for the flag action will be allowed here, and it must be // specified. reason: Object.keys(GQLCOMMENT_FLAG_REASON), }, // Don't Agree { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.DONT_AGREE, + actionType: ACTION_TYPE.DONT_AGREE, }, // Reaction { - item_type: ACTION_ITEM_TYPE.COMMENTS, - action_type: ACTION_TYPE.REACTION, + actionType: ACTION_TYPE.REACTION, }, ]; @@ -133,12 +122,12 @@ const ActionSchema = [ * expected schema, `ActionSchema`. */ export function validateAction( - action: Pick + action: Pick ) { const { error } = Joi.validate( // In typescript, this isn't an issue, but when this is transpiled to // javascript, it will contain additional elements. - pick(action, ["item_type", "action_type", "reason"]), + pick(action, ["actionType", "reason"]), ActionSchema, { presence: "required", @@ -151,13 +140,16 @@ export function validateAction( } } -export type CreateActionInput = Omit; +export type CreateActionInput = Omit< + CommentAction, + "id" | "tenantID" | "createdAt" +>; export interface CreateActionResultObject { /** * action contains the resultant action that was created. */ - action: Action; + action: CommentAction; /** * wasUpserted when true, indicates that this action was just newly created. @@ -172,35 +164,27 @@ export async function createAction( tenantID: string, input: CreateActionInput ): Promise { + const { metadata, ...filter } = input; + // Create a new ID for the action. const id = uuid.v4(); // defaults are the properties set by the application when a new action is // created. - const defaults: Sub = { + const defaults: Sub = { id, - tenant_id: tenantID, - created_at: new Date(), + tenantID, + createdAt: new Date(), }; // Merge the defaults with the input. - const action: Readonly = { + const action: Readonly = { ...defaults, ...input, }; - // This filter ensures that a given user can't flag/respect a given user more - // than once. - const filter: FilterQuery = { - action_type: input.action_type, - item_type: input.item_type, - item_id: input.item_id, - reason: input.reason, - user_id: input.user_id, - }; - // Create the upsert/update operation. - const update: { $setOnInsert: Readonly } = { + const update: { $setOnInsert: Readonly } = { $setOnInsert: action, }; @@ -241,6 +225,21 @@ export async function createActions( return Promise.all(inputs.map(input => createAction(mongo, tenantID, input))); } +export async function retrieveUserAction( + mongo: Db, + tenantID: string, + userID: string | null, + commentID: string, + actionType: ACTION_TYPE +) { + return collection(mongo).findOne({ + tenantID, + commentID, + userID, + actionType, + }); +} + /** * retrieveManyUserActionPresence returns the action presence for a specific * user. @@ -249,21 +248,19 @@ export async function retrieveManyUserActionPresence( mongo: Db, tenantID: string, userID: string | null, - itemType: ACTION_ITEM_TYPE, - itemIDs: string[] + commentIDs: string[] ): Promise { const cursor = await collection(mongo).find( { - tenant_id: tenantID, - user_id: userID, - item_type: itemType, - item_id: { $in: itemIDs }, + tenantID, + userID, + commentID: { $in: commentIDs }, }, { - // We only need the item_id and action_type from the database. + // We only need the commentID and actionType from the database. projection: { - item_id: 1, - action_type: 1, + commentID: 1, + actionType: 1, }, } ); @@ -272,13 +269,13 @@ export async function retrieveManyUserActionPresence( // For each of the actions returned by the query, group the actions by the // item id. Then compute the action presence for each of the actions. - return itemIDs - .map(itemID => actions.filter(action => action.item_id === itemID)) + return commentIDs + .map(commentID => actions.filter(action => action.commentID === commentID)) .map(itemActions => itemActions.reduce( - (actionPresence, { action_type }) => ({ + (actionPresence, { actionType }) => ({ ...actionPresence, - [camelCase(action_type)]: true, + [camelCase(actionType)]: true, }), { reaction: false, @@ -290,8 +287,8 @@ export async function retrieveManyUserActionPresence( } export type RemoveActionInput = Pick< - Action, - "action_type" | "item_type" | "item_id" | "reason" | "user_id" + CommentAction, + "actionType" | "commentID" | "commentRevisionID" | "reason" | "userID" >; /** @@ -301,7 +298,7 @@ export interface RemovedActionResultObject { /** * action is the action that was deleted. */ - action?: Action; + action?: CommentAction; /** * wasRemoved is true when the action that was supposed to be deleted was @@ -319,19 +316,18 @@ export async function removeAction( tenantID: string, input: RemoveActionInput ): Promise { + const { reason, ...rest } = input; + // Extract the filter parameters. - const filter: FilterQuery = { - tenant_id: tenantID, - action_type: input.action_type, - item_type: input.item_type, - item_id: input.item_id, - user_id: input.user_id, + const filter: FilterQuery = { + tenantID, + ...rest, }; // Only add the reason to the filter if it's been specified, otherwise we'll // never match a Flag that has an unspecified reason. - if (input.reason) { - filter.reason = input.reason; + if (reason) { + filter.reason = reason; } // Remove the action from the database, returning the action that was deleted. @@ -354,8 +350,10 @@ export const ACTION_COUNT_JOIN_CHAR = "__"; * * @param actions list of actions to generate the action counts from */ -export function encodeActionCounts(...actions: Action[]): EncodedActionCounts { - const actionCounts: EncodedActionCounts = {}; +export function encodeActionCounts( + ...actions: CommentAction[] +): EncodedCommentActionCounts { + const actionCounts: EncodedCommentActionCounts = {}; // Loop over the actions, and increment them. for (const action of actions) { @@ -377,8 +375,8 @@ export function encodeActionCounts(...actions: Action[]): EncodedActionCounts { * @param actionCounts the encoded action counts to invert */ export function invertEncodedActionCounts( - actionCounts: EncodedActionCounts -): EncodedActionCounts { + actionCounts: EncodedCommentActionCounts +): EncodedCommentActionCounts { for (const key in actionCounts) { if (!actionCounts.hasOwnProperty(key)) { continue; @@ -396,11 +394,11 @@ export function invertEncodedActionCounts( * encodeActionCountKeys encodes the action into string keys which represents * the groupings as seen in `EncodedActionCounts`. */ -function encodeActionCountKeys(action: Action): string[] { - const keys = [action.action_type as string]; +function encodeActionCountKeys(action: CommentAction): string[] { + const keys = [action.actionType as string]; if (action.reason) { keys.push( - [action.action_type as string, action.reason as string].join( + [action.actionType as string, action.reason as string].join( ACTION_COUNT_JOIN_CHAR ) ); @@ -496,10 +494,10 @@ function createEmptyActionCounts(): GQLActionCounts { }; } -export function mergeActionCounts( - actionCounts: EncodedActionCounts[] -): EncodedActionCounts { - const mergedActionCounts: EncodedActionCounts = {}; +export function mergeCommentActionCounts( + actionCounts: EncodedCommentActionCounts[] +): EncodedCommentActionCounts { + const mergedActionCounts: EncodedCommentActionCounts = {}; for (const counts of actionCounts) { for (const [key, count] of Object.entries(counts)) { @@ -515,7 +513,7 @@ export function mergeActionCounts( } export function countTotalActionCounts( - actionCounts: EncodedActionCounts + actionCounts: EncodedCommentActionCounts ): number { return Object.values(actionCounts).reduce((total, count) => total + count, 0); } @@ -527,7 +525,7 @@ export function countTotalActionCounts( * @param encodedActionCounts the action counts to decode */ export function decodeActionCounts( - encodedActionCounts: EncodedActionCounts + encodedActionCounts: EncodedCommentActionCounts ): GQLActionCounts { // Default all the action counts to zero. const actionCounts: GQLActionCounts = createEmptyActionCounts(); @@ -579,37 +577,37 @@ function incrementActionCounts( * removeRootActions will remove all the Action's associated with a given root * identifier. */ -export async function removeRootActions( +export async function removeStoryActions( mongo: Db, tenantID: string, - rootItemID: string + storyID: string ) { return collection(mongo).deleteMany({ - tenant_id: tenantID, - root_item_id: rootItemID, + tenantID, + storyID, }); } /** - * mergeManyRootActions will update many Action `root_item_id'`s from one to + * mergeManyRootActions will update many Action `storyID`'s from one to * another. */ -export async function mergeManyRootActions( +export async function mergeManyStoryActions( mongo: Db, tenantID: string, - newRootItemID: string, - oldRootItemIDs: string[] + newStoryID: string, + oldStoryIDs: string[] ) { return collection(mongo).updateMany( { - tenant_id: tenantID, - root_item_id: { - $in: oldRootItemIDs, + tenantID, + storyID: { + $in: oldStoryIDs, }, }, { $set: { - root_item_id: newRootItemID, + storyID: newStoryID, }, } ); diff --git a/src/core/server/models/action/moderation/comment.ts b/src/core/server/models/action/moderation/comment.ts new file mode 100644 index 000000000..50a00fb89 --- /dev/null +++ b/src/core/server/models/action/moderation/comment.ts @@ -0,0 +1,173 @@ +import { Db } from "mongodb"; +import uuid from "uuid"; + +import { Omit, Sub } from "talk-common/types"; +import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + Connection, + Cursor, + getPageInfo, + nodesToEdges, +} from "talk-server/models/connection"; +import Query from "talk-server/models/query"; +import { TenantResource } from "talk-server/models/tenant"; + +function collection(db: Db) { + return db.collection>( + "commentModerationActions" + ); +} + +/** + * CommentModerationAction stores information around a moderation action that + * was created for a given Comment Revision. + */ +export interface CommentModerationAction extends TenantResource { + readonly id: string; + + /** + * commentID is the ID of the Comment that the moderation action is based on. + */ + commentID: string; + + /** + * commentRevisionID is the ID of the Revision that the moderation action is + * based on. + */ + commentRevisionID: string; + + /** + * status is the GQLCOMMENT_STATUS assigned by the moderator for this + * moderation action. + */ + status: GQLCOMMENT_STATUS; + + /** + * 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. + */ + moderatorID: string | null; + + /** + * createdAt is the time that the moderation action was created on. + */ + createdAt: Date; +} + +export type CreateCommentModerationActionInput = Omit< + CommentModerationAction, + "tenantID" | "id" | "createdAt" +>; + +export async function createCommentModerationAction( + mongo: Db, + tenantID: string, + input: CreateCommentModerationActionInput +) { + // default are the properties set by the application when a new comment + // moderation action is created. + const defaults: Sub< + CommentModerationAction, + CreateCommentModerationActionInput + > = { + id: uuid.v4(), + tenantID, + createdAt: new Date(), + }; + + // Merge the defaults and the input together. + const action: Readonly = { + ...defaults, + ...input, + }; + + // Insert it into the database. + await collection(mongo).insertOne(action); + + return action; +} + +export type CommentModerationActionFilter = Partial< + Pick< + CommentModerationAction, + "commentID" | "commentRevisionID" | "moderatorID" | "status" + > +>; + +export async function retrieveCommentModerationActions( + mongo: Db, + tenantID: string, + filter: CommentModerationActionFilter +) { + const result = await collection(mongo).find({ + tenantID, + ...filter, + }); + + return result.toArray(); +} + +export interface ConnectionInput { + first: number; + after?: Cursor; + filter?: CommentModerationActionFilter; +} + +export async function retrieveCommentModerationActionConnection( + mongo: Db, + tenantID: string, + input: ConnectionInput +): Promise>>> { + // Create the query. + const query = new Query(collection(mongo)).where({ tenantID }); + + // If a filter is being applied, filter it as well. + if (input.filter) { + query.where(input.filter); + } + + return retrieveConnection(input, query); +} + +async function retrieveConnection( + input: ConnectionInput, + query: Query +): Promise>>> { + // Apply the cursor to the query. Currently only supporting sorting by the + // newest first. + query.orderBy({ createdAt: -1 }); + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + // We load one more than the limit so we can determine if there is + // another page of entries. This gets trimmed off below after we've checked to + // see if this constitutes another page of edges. + query.first(input.first + 1); + + // Get the cursor. + const cursor = await query.exec(); + + // Get the comments from the cursor. + const nodes = await cursor.toArray(); + + // Convert the nodes to edges (which will include the extra edge we don't need + // if there is more results). + const edges = nodesToEdges(nodes, a => a.createdAt); + + // Get the pageInfo for the connection. We will use this to also determine if + // we need to trim off the extra edge that we requested by comparing its + // hasNextPage parameter. + const pageInfo = getPageInfo(input, edges); + if (pageInfo.hasNextPage) { + // Because this means that we got one more than expected, we should trim off + // the extra edge that was retrieved. + edges.splice(input.first, 1); + } + + // Return the connection. + return { + edges, + pageInfo, + }; +} diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index e89018fb6..405b15529 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -7,7 +7,7 @@ import { GQLCOMMENT_SORT, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { EncodedActionCounts } from "talk-server/models/action"; +import { EncodedCommentActionCounts } from "talk-server/models/action/comment"; import { Connection, createConnection, @@ -23,82 +23,156 @@ function collection(db: Db) { return db.collection>("comments"); } -export interface BodyHistoryItem { - body: string; - created_at: Date; -} - -export interface StatusHistoryItem { - status: GQLCOMMENT_STATUS; - assigned_by?: string; - created_at: Date; -} - -export interface Comment extends TenantResource { +/** + * Revision stores a Comment's body for a specific edit. Actions can be tied to + * a Revision, as can moderation actions. + */ +export interface Revision { + /** + * id identifies this Revision. + */ readonly id: string; - parent_id?: string; - author_id: string; - story_id: string; + + /** + * body is the body text for this revision. + */ body: string; - body_history: BodyHistoryItem[]; + + /** + * actionCounts is the cached action counts on this revision. + */ + actionCounts: EncodedCommentActionCounts; + + /** + * createdAt is the date that this revision was created at. + */ + createdAt: Date; +} + +/** + * Comment's are created by User's on Stories. Each Comment contains a body, and + * can be moderated by another Moderator or Admin User. + */ +export interface Comment extends TenantResource { + /** + * id identifies this Comment specifically. + */ + readonly id: string; + + /** + * parentID stores the ID of a parent Comment if this Comment is a reply. + */ + parentID?: string; + + /** + * authorID stores the ID of the User that created this Comment. + */ + authorID: string; + + /** + * storyID stores the ID of the Story that this Comment was left on. + */ + storyID: string; + + /** + * revisions stores all the revisions of the Comment body including the most + * recent revision, the last revision is the most recent. + */ + revisions: Revision[]; + + /** + * status is the current Comment Status. + */ status: GQLCOMMENT_STATUS; - status_history: StatusHistoryItem[]; - action_counts: EncodedActionCounts; - grandparent_ids: string[]; - reply_ids: string[]; - reply_count: number; - created_at: Date; - deleted_at?: Date; + + /** + * actionCounts stores a cached count of all the Action's against this + * Comment. + */ + actionCounts: EncodedCommentActionCounts; + + /** + * grandparentIDs stores all the ID's of all the Comment's that came before. + * This prevents the need for performing multiple queries to retrieve the + * Comment ancestors. + */ + grandparentIDs: string[]; + + /** + * replyIDs are the ID's of all the Comment's that are direct replies. + */ + replyIDs: string[]; + + /** + * replyCount is the count of direct replies. It is stored as a separate value + * here even though the replyIDs field technically contained the same data in + * it's length because we needed to sort by this field sometimes. + */ + replyCount: number; + + /** + * createdAt is the date that this Comment was created. + */ + createdAt: Date; + + /** + * deletedAt is the date that this Comment was deleted on. If null or + * undefined, this Comment is not deleted. + */ + deletedAt?: Date; + + /** + * metadata stores the deep Comment properties. + */ metadata?: Record; } export type CreateCommentInput = Omit< Comment, | "id" - | "tenant_id" - | "created_at" - | "reply_ids" - | "reply_count" - | "body_history" - | "status_history" ->; + | "tenantID" + | "createdAt" + | "replyIDs" + | "replyCount" + | "actionCounts" + | "revisions" +> & + Required>; export async function createComment( db: Db, tenantID: string, input: CreateCommentInput ) { - const now = new Date(); + const createdAt = new Date(); // Pull out some useful properties from the input. - const { body, status } = input; + const { body, ...rest } = input; + + // Generate the revision. + const revision: Revision = { + id: uuid.v4(), + body, + actionCounts: {}, + createdAt, + }; // default are the properties set by the application when a new comment is // created. const defaults: Sub = { id: uuid.v4(), - tenant_id: tenantID, - created_at: now, - reply_ids: [], - reply_count: 0, - body_history: [ - { - body, - created_at: now, - }, - ], - status_history: [ - { - status, - created_at: now, - }, - ], + tenantID, + createdAt, + replyIDs: [], + replyCount: 0, + actionCounts: {}, + revisions: [revision], }; // Merge the defaults and the input together. const comment: Readonly = { ...defaults, - ...input, + ...rest, }; // Insert it into the database. @@ -120,12 +194,12 @@ export async function pushChildCommentIDOntoParent( // This pushes the new child ID onto the parent comment. const result = await collection(mongo).findOneAndUpdate( { - tenant_id: tenantID, + tenantID, id: parentID, }, { - $push: { reply_ids: childID }, - $inc: { reply_count: 1 }, + $push: { replyIDs: childID }, + $inc: { replyCount: 1 }, } ); @@ -134,7 +208,7 @@ export async function pushChildCommentIDOntoParent( export type EditCommentInput = Pick< Comment, - "id" | "author_id" | "body" | "status" | "metadata" + "id" | "authorID" | "status" | "metadata" > & { /** * lastEditableCommentCreatedAt is the date that the last comment would have @@ -142,18 +216,22 @@ export type EditCommentInput = Pick< * `editCommentWindowLength` property. */ lastEditableCommentCreatedAt: Date; -}; +} & Required>; export async function editComment( db: Db, tenantID: string, input: EditCommentInput ) { + // TODO: (wyattjoh) now that we have revisions, do we really have this restriction? + + // Only comments with the following status's can be edited. const EDITABLE_STATUSES = [ GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.PREMOD, GQLCOMMENT_STATUS.ACCEPTED, ]; + const createdAt = new Date(); const { @@ -161,28 +239,33 @@ export async function editComment( body, lastEditableCommentCreatedAt, status, - author_id, + authorID, metadata, } = input; - // TODO: (wyattjoh) consider resetting the action counts if we're starting fresh with a new comment + // Generate the revision. + const revision: Revision = { + id: uuid.v4(), + body, + actionCounts: {}, + createdAt, + }; const result = await collection(db).findOneAndUpdate( { id, - tenant_id: tenantID, - author_id, + tenantID, + authorID, status: { $in: EDITABLE_STATUSES, }, - deleted_at: null, - created_at: { + deletedAt: null, + createdAt: { $gt: lastEditableCommentCreatedAt, }, }, { $set: { - body, status, // Embed all the metadata properties, this may override the existing // metadata, but we won't replace metadata that has been recalculated. @@ -190,14 +273,7 @@ export async function editComment( ...dotize({ metadata }), }, $push: { - body_history: { - body, - created_at: createdAt, - }, - status_history: { - type: status, - created_at: createdAt, - }, + revisions: revision, }, }, // False to return the updated document instead of the original @@ -212,7 +288,7 @@ export async function editComment( throw new Error("comment not found"); } - if (comment.author_id !== author_id) { + if (comment.authorID !== authorID) { // TODO: (wyattjoh) return better error throw new Error("comment author mismatch"); } @@ -224,7 +300,7 @@ export async function editComment( } // Check to see if the edit window expired. - if (comment.created_at <= lastEditableCommentCreatedAt) { + if (comment.createdAt <= lastEditableCommentCreatedAt) { // TODO: (wyattjoh) return better error throw new Error("edit window expired"); } @@ -237,7 +313,7 @@ export async function editComment( } export async function retrieveComment(db: Db, tenantID: string, id: string) { - return collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenantID }); } export async function retrieveManyComments( @@ -249,7 +325,7 @@ export async function retrieveManyComments( id: { $in: ids, }, - tenant_id: tenantID, + tenantID, }); const comments = await cursor.toArray(); @@ -269,7 +345,7 @@ function cursorGetterFactory( switch (input.orderBy) { case GQLCOMMENT_SORT.CREATED_AT_DESC: case GQLCOMMENT_SORT.CREATED_AT_ASC: - return comment => comment.created_at; + return comment => comment.createdAt; case GQLCOMMENT_SORT.REPLIES_DESC: case GQLCOMMENT_SORT.RESPECT_DESC: return (_, index) => @@ -294,9 +370,9 @@ export async function retrieveCommentRepliesConnection( ) { // Create the query. const query = new Query(collection(db)).where({ - tenant_id: tenantID, - story_id: storyID, - parent_id: parentID, + tenantID, + storyID, + parentID, }); // Return a connection for the comments query. @@ -319,7 +395,7 @@ export async function retrieveCommentParentsConnection( { last: limit, before: skip = 0 }: { last: number; before?: number } ): Promise>>> { // Return nothing if this comment does not have any parents. - if (!comment.parent_id) { + if (!comment.parentID) { return createConnection({ pageInfo: { hasNextPage: false, @@ -334,7 +410,7 @@ export async function retrieveCommentParentsConnection( return createConnection({ pageInfo: { hasNextPage: false, - hasPreviousPage: !!comment.parent_id, + hasPreviousPage: !!comment.parentID, endCursor: 0, startCursor: 0, }, @@ -344,7 +420,7 @@ export async function retrieveCommentParentsConnection( // If the last paramter is 1, and the after paramter is either unset or equal // to zero, then all we have to return is the direct parent. if (limit === 1 && skip <= 0) { - const parent = await retrieveComment(mongo, tenantID, comment.parent_id); + const parent = await retrieveComment(mongo, tenantID, comment.parentID); if (!parent) { throw new Error("parent comment not found"); } @@ -353,7 +429,7 @@ export async function retrieveCommentParentsConnection( edges: [{ node: parent, cursor: 1 }], pageInfo: { hasNextPage: false, - hasPreviousPage: comment.grandparent_ids.length > 0, + hasPreviousPage: comment.grandparentIDs.length > 0, endCursor: 1, startCursor: 1, }, @@ -361,7 +437,7 @@ export async function retrieveCommentParentsConnection( } // Create a list of all the comment parent ids, in reverse order. - const parentIDs = [comment.parent_id, ...comment.grandparent_ids.reverse()]; + const parentIDs = [comment.parentID, ...comment.grandparentIDs.reverse()]; // Fetch the subset of the comment id's that we are going to query for. const parentIDSubset = parentIDs.slice(skip, skip + limit); @@ -415,9 +491,15 @@ export async function retrieveCommentStoryConnection( ) { // Create the query. const query = new Query(collection(db)).where({ - tenant_id: tenantID, - story_id: storyID, - parent_id: null, + tenantID, + storyID, + // Only get Comments that are top level. If the client wants to load another + // layer, they can request another nested connection. + parentID: null, + // Only get Comment's that are visible. + status: { + $in: [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.ACCEPTED], + }, }); // Return a connection for the comments query. @@ -440,8 +522,8 @@ export async function retrieveCommentUserConnection( ) { // Create the query. const query = new Query(collection(db)).where({ - tenant_id: tenantID, - author_id: userID, + tenantID, + authorID: userID, }); // Return a connection for the comments query. @@ -498,25 +580,25 @@ async function retrieveConnection( function applyInputToQuery(input: ConnectionInput, query: Query) { switch (input.orderBy) { case GQLCOMMENT_SORT.CREATED_AT_DESC: - query.orderBy({ created_at: -1 }); + query.orderBy({ createdAt: -1 }); if (input.after) { - query.where({ created_at: { $lt: input.after as Date } }); + query.where({ createdAt: { $lt: input.after as Date } }); } break; case GQLCOMMENT_SORT.CREATED_AT_ASC: - query.orderBy({ created_at: 1 }); + query.orderBy({ createdAt: 1 }); if (input.after) { - query.where({ created_at: { $gt: input.after as Date } }); + query.where({ createdAt: { $gt: input.after as Date } }); } break; case GQLCOMMENT_SORT.REPLIES_DESC: - query.orderBy({ reply_count: -1, created_at: -1 }); + query.orderBy({ replyCount: -1, createdAt: -1 }); if (input.after) { query.after(input.after as number); } break; case GQLCOMMENT_SORT.RESPECT_DESC: - query.orderBy({ "action_counts.REACTION": -1, created_at: -1 }); + query.orderBy({ "actionCounts.REACTION": -1, createdAt: -1 }); if (input.after) { query.after(input.after as number); } @@ -524,6 +606,52 @@ function applyInputToQuery(input: ConnectionInput, query: Query) { } } +export interface UpdateCommentStatus { + comment: Readonly; + oldStatus: GQLCOMMENT_STATUS; +} + +export async function updateCommentStatus( + mongo: Db, + tenantID: string, + id: string, + revisionID: string, + status: GQLCOMMENT_STATUS +): Promise { + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + "revisions.id": revisionID, + status: { + $ne: status, + }, + }, + { + $set: { status }, + }, + { + // True to return the original document instead of the updated + // document. + returnOriginal: true, + } + ); + if (!result.value) { + return null; + } + + // Grab the old status. + const oldStatus = result.value.status; + + return { + comment: { + ...result.value, + status, + }, + oldStatus, + }; +} + /** * updateCommentActionCounts will update the given comment's action counts. * @@ -536,19 +664,30 @@ export async function updateCommentActionCounts( mongo: Db, tenantID: string, id: string, - actionCounts: EncodedActionCounts + revisionID: string, + actionCounts: EncodedCommentActionCounts ) { const result = await collection(mongo).findOneAndUpdate( - { id, tenant_id: tenantID }, + { id, tenantID }, // Update all the specific action counts that are associated with each of // the counts. - { $inc: dotize({ action_counts: actionCounts }) }, - // False to return the updated document instead of the original - // document. - { returnOriginal: false } + { + $inc: dotize({ + actionCounts, + "revisions.$[revision]": { actionCounts }, + }), + }, + { + // Add an ArrayFilter to only update one of the OpenID Connect + // integrations. + arrayFilters: [{ "revision.id": revisionID }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } ); - return result.value; + return result.value || null; } /** @@ -562,8 +701,8 @@ export async function removeStoryComments( ) { // Delete all the comments written on a specific story. return collection(mongo).deleteMany({ - tenant_id: tenantID, - story_id: storyID, + tenantID, + storyID, }); } @@ -578,15 +717,26 @@ export async function mergeManyCommentStories( ) { return collection(mongo).updateMany( { - tenant_id: tenantID, - story_id: { + tenantID, + storyID: { $in: oldStoryIDs, }, }, { $set: { - story_id: newStoryID, + storyID: newStoryID, }, } ); } + +/** + * getLatestRevision will get the latest revision from a Comment. + * + * @param comment the comment that contains the revisions + */ +export function getLatestRevision( + comment: Pick +): Revision { + return comment.revisions[comment.revisions.length - 1]; +} diff --git a/src/core/server/models/story.ts b/src/core/server/models/story.ts index 6a28b08ce..f947343b7 100644 --- a/src/core/server/models/story.ts +++ b/src/core/server/models/story.ts @@ -7,7 +7,7 @@ import { GQLCOMMENT_STATUS, GQLStoryMetadata, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { EncodedActionCounts } from "talk-server/models/action"; +import { EncodedCommentActionCounts } from "talk-server/models/action/comment"; import { ModerationSettings } from "talk-server/models/settings"; import { TenantResource } from "talk-server/models/tenant"; @@ -43,15 +43,15 @@ export interface Story extends TenantResource { scrapedAt?: Date; /** - * action_counts stores all the action counts for all Comment's on this Story. + * actionCounts stores all the action counts for all Comment's on this Story. */ - action_counts: EncodedActionCounts; + commentActionCounts: EncodedCommentActionCounts; /** - * comment_counts stores the different counts for each comment on the Story + * commentCounts stores the different counts for each comment on the Story * according to their statuses. */ - comment_counts: CommentStatusCounts; + commentCounts: CommentStatusCounts; /** * settings provides a point where the settings can be overridden for a @@ -66,9 +66,9 @@ export interface Story extends TenantResource { closedAt?: Date | false; /** - * created_at is the date that the Story was added to the Talk database. + * createdAt is the date that the Story was added to the Talk database. */ - created_at: Date; + createdAt: Date; } export interface UpsertStoryInput { @@ -84,15 +84,15 @@ export async function upsertStory( const now = new Date(); // Create the story, optionally sourcing the id from the input, additionally - // porting in the tenant_id. + // porting in the tenantID. const update: { $setOnInsert: Story } = { $setOnInsert: { id: id ? id : uuid.v4(), url, - tenant_id: tenantID, - created_at: now, - action_counts: {}, - comment_counts: createEmptyCommentCounts(), + tenantID, + createdAt: now, + commentActionCounts: {}, + commentCounts: createEmptyCommentCounts(), }, }; @@ -101,7 +101,7 @@ export async function upsertStory( const result = await collection(db).findOneAndUpdate( { url, - tenant_id: tenantID, + tenantID, }, update, { @@ -136,11 +136,11 @@ export async function updateCommentStatusCount( const result = await collection(mongo).findOneAndUpdate( { id, - tenant_id: tenantID, + tenantID, }, // Update all the specific comment status counts that are associated with // each of the counts. - { $inc: dotize({ comment_counts: commentStatusCounts }) }, + { $inc: dotize({ commentCounts: commentStatusCounts }) }, // False to return the updated document instead of the original // document. { returnOriginal: false } @@ -238,10 +238,10 @@ export async function createStory( ...input, id, url, - tenant_id: tenantID, - created_at: now, - action_counts: {}, - comment_counts: createEmptyCommentCounts(), + tenantID, + createdAt: now, + commentActionCounts: {}, + commentCounts: createEmptyCommentCounts(), }; try { @@ -267,11 +267,11 @@ export async function retrieveStoryByURL( tenantID: string, url: string ) { - return collection(db).findOne({ url, tenant_id: tenantID }); + return collection(db).findOne({ url, tenantID }); } export async function retrieveStory(db: Db, tenantID: string, id: string) { - return collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenantID }); } export async function retrieveManyStories( @@ -281,7 +281,7 @@ export async function retrieveManyStories( ) { const cursor = await collection(db).find({ id: { $in: ids }, - tenant_id: tenantID, + tenantID, }); const stories = await cursor.toArray(); @@ -296,7 +296,7 @@ export async function retrieveManyStoriesByURL( ) { const cursor = await collection(db).find({ url: { $in: urls }, - tenant_id: tenantID, + tenantID, }); const stories = await cursor.toArray(); @@ -306,7 +306,7 @@ export async function retrieveManyStoriesByURL( export type UpdateStoryInput = Omit< Partial, - "id" | "tenant_id" | "created_at" + "id" | "tenantID" | "createdAt" >; export async function updateStory( @@ -320,13 +320,13 @@ export async function updateStory( $set: { ...dotize(input, { embedArrays: true }), // Always update the updated at time. - updated_at: new Date(), + updatedAt: new Date(), }, }; try { const result = await collection(db).findOneAndUpdate( - { id, tenant_id: tenantID }, + { id, tenantID }, update, // False to return the updated document instead of the original // document. @@ -359,13 +359,13 @@ export async function updateStoryActionCounts( mongo: Db, tenantID: string, id: string, - actionCounts: EncodedActionCounts + actionCounts: EncodedCommentActionCounts ) { const result = await collection(mongo).findOneAndUpdate( - { id, tenant_id: tenantID }, + { id, tenantID }, // Update all the specific action counts that are associated with each of // the counts. - { $inc: dotize({ action_counts: actionCounts }) }, + { $inc: dotize({ actionCounts }) }, // False to return the updated document instead of the original // document. { returnOriginal: false } @@ -377,7 +377,7 @@ export async function updateStoryActionCounts( export async function removeStory(mongo: Db, tenantID: string, id: string) { const result = await collection(mongo).findOneAndDelete({ id, - tenant_id: tenantID, + tenantID, }); return result.value || null; @@ -392,7 +392,7 @@ export async function removeStories( ids: string[] ) { return collection(mongo).deleteMany({ - tenant_id: tenantID, + tenantID, id: { $in: ids, }, diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 7636029dc..dea8849fe 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -20,7 +20,11 @@ function dotizeDropNull(o: Record, options?: DotizeOptions) { } export interface TenantResource { - readonly tenant_id: string; + /** + * tenantID is the reference to the specific Tenant that owns this particular + * resource. + */ + readonly tenantID: string; } /** diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 52cfc74c1..dc78afb8b 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -7,7 +7,6 @@ import { GQLUSER_ROLE, GQLUSER_USERNAME_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { EncodedActionCounts } from "talk-server/models/action"; import { FilterQuery } from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; @@ -57,9 +56,9 @@ export interface Token { export interface UserStatusHistory { status: T; - assigned_by?: string; + assignedBy?: string; reason?: string; - created_at: Date; + createdAt: Date; } export interface UserStatusItem { @@ -80,25 +79,18 @@ export interface User extends TenantResource { password?: string; avatar?: string; email?: string; - email_verified?: boolean; + emailVerified?: boolean; profiles: Profile[]; tokens: Token[]; role: GQLUSER_ROLE; status: UserStatus; - action_counts: EncodedActionCounts; - ignored_users: string[]; - created_at: Date; + ignoredUserIDs: string[]; + createdAt: Date; } export type UpsertUserInput = Omit< User, - | "id" - | "tenant_id" - | "tokens" - | "status" - | "action_counts" - | "ignored_users" - | "created_at" + "id" | "tenantID" | "tokens" | "status" | "ignoredUserIDs" | "createdAt" >; export async function upsertUser( @@ -115,10 +107,9 @@ export async function upsertUser( // created. const defaults: Sub = { id, - tenant_id: tenantID, + tenantID, tokens: [], - action_counts: {}, - ignored_users: [], + ignoredUserIDs: [], status: { banned: { status: false, @@ -135,7 +126,7 @@ export async function upsertUser( history: [], }, }, - created_at: now, + createdAt: now, }; let hashedPassword; @@ -202,7 +193,7 @@ const createUpsertUserFilter = (user: Readonly) => { }; export async function retrieveUser(db: Db, tenantID: string, id: string) { - return collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenantID }); } export async function retrieveManyUsers( @@ -214,7 +205,7 @@ export async function retrieveManyUsers( id: { $in: ids, }, - tenant_id: tenantID, + tenantID, }); const users = await cursor.toArray(); @@ -228,7 +219,7 @@ export async function retrieveUserWithProfile( profile: Profile ) { return collection(db).findOne({ - tenant_id: tenantID, + tenantID, profiles: { $elemMatch: profile, }, @@ -242,7 +233,7 @@ export async function updateUserRole( role: GQLUSER_ROLE ) { const result = await collection(db).findOneAndUpdate( - { id, tenant_id: tenantID }, + { id, tenantID }, { $set: { role } }, { returnOriginal: false } ); diff --git a/src/core/server/queue/Task.ts b/src/core/server/queue/Task.ts index c36bff79c..2834456c1 100644 --- a/src/core/server/queue/Task.ts +++ b/src/core/server/queue/Task.ts @@ -1,4 +1,6 @@ import Queue, { Job, Queue as QueueType } from "bull"; +import Logger from "bunyan"; + import logger from "talk-server/logger"; export interface TaskOptions { @@ -10,9 +12,12 @@ export interface TaskOptions { export default class Task { private options: TaskOptions; private queue: QueueType; + private log: Logger; + constructor(options: TaskOptions) { this.queue = new Queue(options.jobName, options.queue); this.options = options; + this.log = logger.child({ jobName: options.jobName }); // Sets up and attaches the job processor to the queue. this.setupAndAttachProcessor(); @@ -31,31 +36,21 @@ export default class Task { removeOnComplete: true, }); - logger.trace( - { job_id: job.id, job_name: this.options.jobName }, - "added job to queue" - ); + this.log.trace({ jobID: job.id }, "added job to queue"); return job; } private setupAndAttachProcessor() { this.queue.process(async (job: Job) => { - logger.trace( - { job_id: job.id, job_name: this.options.jobName }, - "processing job from queue" - ); + const log = this.log.child({ jobID: job.id }); + + log.trace("processing job from queue"); // Send the job off to the job processor to be handled. const promise: U = await this.options.jobProcessor(job); - logger.trace( - { job_id: job.id, job_name: this.options.jobName }, - "processing completed" - ); + log.trace("processing completed"); return promise; }); - logger.trace( - { job_name: this.options.jobName }, - "registered processor for job type" - ); + this.log.trace("registered processor for job type"); } } diff --git a/src/core/server/queue/tasks/Task.ts b/src/core/server/queue/tasks/Task.ts index e34b24c50..3c6ff332f 100644 --- a/src/core/server/queue/tasks/Task.ts +++ b/src/core/server/queue/tasks/Task.ts @@ -33,7 +33,7 @@ export default class Task { }); logger.trace( - { job_id: job.id, job_name: this.options.jobName }, + { jobID: job.id, jobName: this.options.jobName }, "added job to queue" ); return job; @@ -42,21 +42,21 @@ export default class Task { private setupAndAttachProcessor() { this.queue.process(async (job: Job) => { logger.trace( - { job_id: job.id, job_name: this.options.jobName }, + { jobID: job.id, jobName: this.options.jobName }, "processing job from queue" ); // Send the job off to the job processor to be handled. const promise: U = await this.options.jobProcessor(job); logger.trace( - { job_id: job.id, job_name: this.options.jobName }, + { jobID: job.id, jobName: this.options.jobName }, "processing completed" ); return promise; }); logger.trace( - { job_name: this.options.jobName }, + { jobName: this.options.jobName }, "registered processor for job type" ); } diff --git a/src/core/server/queue/tasks/mailer/index.ts b/src/core/server/queue/tasks/mailer/index.ts index 39a570fb4..13cb5de32 100644 --- a/src/core/server/queue/tasks/mailer/index.ts +++ b/src/core/server/queue/tasks/mailer/index.ts @@ -55,8 +55,8 @@ const createJobProcessor = (options: MailProcessorOptions) => { if (err) { logger.error( { - job_id: job.id, - job_name: JOB_NAME, + jobID: job.id, + jobName: JOB_NAME, err, }, "job data did not match expected schema" @@ -67,52 +67,32 @@ const createJobProcessor = (options: MailProcessorOptions) => { // Pull the data out of the validated model. const { message, tenantID } = value; + const log = logger.child({ + jobID: job.id, + jobName: JOB_NAME, + tenantID, + }); + // Get the referenced tenant so we know who to send it from. const tenant = await tenantCache.retrieveByID(tenantID); if (!tenant) { - logger.error( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "referenced tenant was not found" - ); + log.error("referenced tenant was not found"); return; } if (!tenant.email.enabled) { - logger.error( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "not sending email, it was disabled" - ); + log.error("not sending email, it was disabled"); return; } if (!tenant.email.smtpURI) { - logger.error( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "email was enabled but the smtpURI configuration was missing" - ); + log.error("email was enabled but the smtpURI configuration was missing"); return; } if (!tenant.email.fromAddress) { // TODO: possibly have fallback email address? - logger.error( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, + log.error( "email was enabled but the fromAddress configuration was missing" ); return; @@ -126,33 +106,12 @@ const createJobProcessor = (options: MailProcessorOptions) => { // Set the transport back into the cache. cache.set(tenantID, transport); - logger.debug( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "transport was not cached" - ); + log.debug("transport was not cached"); } else { - logger.debug( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "transport was cached" - ); + log.debug("transport was cached"); } - logger.debug( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "starting to send the email" - ); + log.debug("starting to send the email"); // Send the mail message. await transport.sendMail({ @@ -162,14 +121,7 @@ const createJobProcessor = (options: MailProcessorOptions) => { from: tenant.email.fromAddress, }); - logger.debug( - { - job_id: job.id, - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "sent the email" - ); + log.debug("sent the email"); }; }; @@ -203,29 +155,22 @@ export class Mailer { public async add({ template, ...rest }: MailerInput) { const { tenantID } = rest; + const log = logger.child({ + jobName: JOB_NAME, + tenantID, + }); + // All email templates require the tenant in order to insert the footer, so // load it from the tenant cache here. const tenant = await this.tenantCache.retrieveByID(tenantID); if (!tenant) { - logger.error( - { - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "referenced tenant was not found" - ); + log.error("referenced tenant was not found"); // TODO: (wyattjoh) maybe throw an error here? return; } if (!tenant.email.enabled) { - logger.error( - { - job_name: JOB_NAME, - tenant_id: tenantID, - }, - "not adding email, it was disabled" - ); + log.error("not adding email, it was disabled"); // TODO: (wyattjoh) maybe throw an error here? return; } diff --git a/src/core/server/queue/tasks/scraper/index.ts b/src/core/server/queue/tasks/scraper/index.ts index a6287f20b..1c891156b 100644 --- a/src/core/server/queue/tasks/scraper/index.ts +++ b/src/core/server/queue/tasks/scraper/index.ts @@ -24,11 +24,11 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async ( const { storyID, storyURL, tenantID } = job.data; const log = logger.child({ - job_id: job.id, - job_name: JOB_NAME, - story_id: storyID, - story_url: storyURL, - tenant_id: tenantID, + jobID: job.id, + jobName: JOB_NAME, + storyID, + storyURL, + tenantID, }); log.debug("starting to scrape the story"); diff --git a/src/core/server/services/comments/actions.ts b/src/core/server/services/comments/actions.ts index b39c91d85..1884c1552 100644 --- a/src/core/server/services/comments/actions.ts +++ b/src/core/server/services/comments/actions.ts @@ -3,7 +3,6 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types"; import { - ACTION_ITEM_TYPE, ACTION_TYPE, CreateActionInput, createActions, @@ -11,8 +10,10 @@ import { invertEncodedActionCounts, removeAction, RemoveActionInput, -} from "talk-server/models/action"; + retrieveUserAction, +} from "talk-server/models/action/comment"; import { + getLatestRevision, retrieveComment, updateCommentActionCounts, } from "talk-server/models/comment"; @@ -21,8 +22,8 @@ import { updateStoryActionCounts } from "talk-server/models/story"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; -export type CreateAction = Omit & - Required>; +export type CreateAction = Omit & + Required>; export async function addCommentActions( mongo: Db, @@ -43,11 +44,15 @@ export async function addCommentActions( // Compute the action counts. const actionCounts = encodeActionCounts(...upsertedActions); + // Grab the last revision (the most recent). + const revision = getLatestRevision(comment); + // Update the comment action counts here. const updatedComment = await updateCommentActionCounts( mongo, tenant.id, comment.id, + revision.id, actionCounts ); @@ -55,7 +60,7 @@ export async function addCommentActions( await updateStoryActionCounts( mongo, tenant.id, - comment.story_id, + comment.storyID, actionCounts ); @@ -77,17 +82,17 @@ async function addCommentAction( tenant: Tenant, input: CreateActionInput ): Promise> { - const comment = await retrieveComment(mongo, tenant.id, input.item_id); + const comment = await retrieveComment(mongo, tenant.id, input.commentID); if (!comment) { // TODO: replace to match error returned by the models/comments.ts throw new Error("comment not found"); } // Store the story ID on the action as a story_id. - input.root_item_id = comment.story_id; + input.storyID = comment.storyID; // We have to perform a type assertion here because for some reason, the type - // coercion is not determining that because we filled in the `root_item_id` + // coercion is not determining that because we filled in the `storyID` // above, that at this point, it satisfies the CreateAction type. return addCommentActions(mongo, tenant, comment, [input as CreateAction]); } @@ -95,17 +100,36 @@ async function addCommentAction( export async function removeCommentAction( mongo: Db, tenant: Tenant, - input: RemoveActionInput + input: Omit ): Promise> { // Get the Comment that we are leaving the Action on. - const comment = await retrieveComment(mongo, tenant.id, input.item_id); + const comment = await retrieveComment(mongo, tenant.id, input.commentID); if (!comment) { // TODO: replace to match error returned by the models/comments.ts throw new Error("comment not found"); } + // Get the revision for the specific action being removed. + const action = await retrieveUserAction( + mongo, + tenant.id, + input.userID, + input.commentID, + input.actionType + ); + if (!action) { + // The action that is trying to get removed does not exist! + return comment; + } + + // Grab the revision ID out of the action. + const { commentID, commentRevisionID } = action; + // Create each of the actions, returning each of the action results. - const { wasRemoved, action } = await removeAction(mongo, tenant.id, input); + const { wasRemoved } = await removeAction(mongo, tenant.id, { + ...input, + commentRevisionID, + }); if (wasRemoved) { // Compute the action counts, and invert them (because we're deleting an // action). @@ -115,7 +139,8 @@ export async function removeCommentAction( const updatedComment = await updateCommentActionCounts( mongo, tenant.id, - comment.id, + commentID, + commentRevisionID, actionCounts ); @@ -123,7 +148,7 @@ export async function removeCommentAction( await updateStoryActionCounts( mongo, tenant.id, - comment.story_id, + comment.storyID, actionCounts ); @@ -139,7 +164,10 @@ export async function removeCommentAction( return comment; } -export type CreateCommentReaction = Pick; +export type CreateCommentReaction = Pick< + CreateActionInput, + "commentID" | "commentRevisionID" +>; export async function createReaction( mongo: Db, @@ -148,14 +176,14 @@ export async function createReaction( input: CreateCommentReaction ) { return addCommentAction(mongo, tenant, { - action_type: ACTION_TYPE.REACTION, - item_type: ACTION_ITEM_TYPE.COMMENTS, - item_id: input.item_id, - user_id: author.id, + actionType: ACTION_TYPE.REACTION, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + userID: author.id, }); } -export type RemoveCommentReaction = Pick; +export type RemoveCommentReaction = Pick; export async function removeReaction( mongo: Db, @@ -164,14 +192,16 @@ export async function removeReaction( input: RemoveCommentReaction ) { return removeCommentAction(mongo, tenant, { - action_type: ACTION_TYPE.REACTION, - item_type: ACTION_ITEM_TYPE.COMMENTS, - item_id: input.item_id, - user_id: author.id, + actionType: ACTION_TYPE.REACTION, + commentID: input.commentID, + userID: author.id, }); } -export type CreateCommentDontAgree = Pick; +export type CreateCommentDontAgree = Pick< + CreateActionInput, + "commentID" | "commentRevisionID" +>; export async function createDontAgree( mongo: Db, @@ -180,14 +210,14 @@ export async function createDontAgree( input: CreateCommentDontAgree ) { return addCommentAction(mongo, tenant, { - action_type: ACTION_TYPE.DONT_AGREE, - item_type: ACTION_ITEM_TYPE.COMMENTS, - item_id: input.item_id, - user_id: author.id, + actionType: ACTION_TYPE.DONT_AGREE, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + userID: author.id, }); } -export type RemoveCommentDontAgree = Pick; +export type RemoveCommentDontAgree = Pick; export async function removeDontAgree( mongo: Db, @@ -196,14 +226,16 @@ export async function removeDontAgree( input: RemoveCommentDontAgree ) { return removeCommentAction(mongo, tenant, { - action_type: ACTION_TYPE.DONT_AGREE, - item_type: ACTION_ITEM_TYPE.COMMENTS, - item_id: input.item_id, - user_id: author.id, + actionType: ACTION_TYPE.DONT_AGREE, + commentID: input.commentID, + userID: author.id, }); } -export type CreateCommentFlag = Pick & { +export type CreateCommentFlag = Pick< + CreateActionInput, + "commentID" | "commentRevisionID" +> & { reason: GQLCOMMENT_FLAG_REPORTED_REASON; }; @@ -214,15 +246,15 @@ export async function createFlag( input: CreateCommentFlag ) { return addCommentAction(mongo, tenant, { - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: input.reason, - item_type: ACTION_ITEM_TYPE.COMMENTS, - item_id: input.item_id, - user_id: author.id, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + userID: author.id, }); } -export type RemoveCommentFlag = Pick; +export type RemoveCommentFlag = Pick; export async function removeFlag( mongo: Db, @@ -231,9 +263,8 @@ export async function removeFlag( input: RemoveCommentFlag ) { return removeCommentAction(mongo, tenant, { - action_type: ACTION_TYPE.FLAG, - item_type: ACTION_ITEM_TYPE.COMMENTS, - item_id: input.item_id, - user_id: author.id, + actionType: ACTION_TYPE.FLAG, + commentID: input.commentID, + userID: author.id, }); } diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index c08221bbe..165e0230c 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,12 +1,12 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; -import { ACTION_ITEM_TYPE } from "talk-server/models/action"; import { createComment, CreateCommentInput, editComment, EditCommentInput, + getLatestRevision, pushChildCommentIDOntoParent, retrieveComment, } from "talk-server/models/comment"; @@ -25,7 +25,7 @@ import { Request } from "talk-server/types/express"; export type CreateComment = Omit< CreateCommentInput, - "status" | "action_counts" | "metadata" | "grandparent_ids" + "status" | "metadata" | "grandparentIDs" >; export async function create( @@ -36,7 +36,7 @@ export async function create( req?: Request ) { // Grab the story that we'll use to check moderation pieces with. - const story = await retrieveStory(mongo, tenant.id, input.story_id); + const story = await retrieveStory(mongo, tenant.id, input.storyID); if (!story) { // TODO: (wyattjoh) return better error. throw new Error("story referenced does not exist"); @@ -45,9 +45,9 @@ export async function create( // TODO: (wyattjoh) Check that the story was visible. const grandparentIDs: string[] = []; - if (input.parent_id) { + if (input.parentID) { // Check to see that the reference parent ID exists. - const parent = await retrieveComment(mongo, tenant.id, input.parent_id); + const parent = await retrieveComment(mongo, tenant.id, input.parentID); if (!parent) { // TODO: (wyattjoh) return better error. throw new Error("parent comment referenced does not exist"); @@ -56,10 +56,10 @@ export async function create( // TODO: (wyattjoh) Check that the parent comment was visible. // Push the parent's parent id's into the comment's grandparent id's. - grandparentIDs.push(...parent.grandparent_ids); - if (parent.parent_id) { + grandparentIDs.push(...parent.grandparentIDs); + if (parent.parentID) { // If this parent has a parent, push it down as well. - grandparentIDs.push(parent.parent_id); + grandparentIDs.push(parent.parentID); } } @@ -76,23 +76,22 @@ export async function create( let comment = await createComment(mongo, tenant.id, { ...input, status, - action_counts: {}, - grandparent_ids: grandparentIDs, + grandparentIDs, metadata, }); if (actions.length > 0) { - // The actions coming from the moderation phases didn't know the item_id + // The actions coming from the moderation phases didn't know the commentID // at the time, and we didn't want the repetitive nature of adding the // item_type each time, so this mapping function adds them! const inputs = actions.map( (action): CreateAction => ({ ...action, - item_id: comment.id, - item_type: ACTION_ITEM_TYPE.COMMENTS, + commentID: comment.id, + commentRevisionID: getLatestRevision(comment!).id, // Store the Story ID on the action. - root_item_id: story.id, + storyID: story.id, }) ); @@ -100,12 +99,12 @@ export async function create( comment = await addCommentActions(mongo, tenant, comment, inputs); } - if (input.parent_id) { + if (input.parentID) { // Push the child's ID onto the parent. await pushChildCommentIDOntoParent( mongo, tenant.id, - input.parent_id, + input.parentID, comment.id ); } @@ -120,7 +119,7 @@ export async function create( export type EditComment = Omit< EditCommentInput, - "status" | "author_id" | "lastEditableCommentCreatedAt" + "status" | "authorID" | "lastEditableCommentCreatedAt" >; export async function edit( @@ -138,7 +137,7 @@ export async function edit( } // Grab the story that we'll use to check moderation pieces with. - const story = await retrieveStory(mongo, tenant.id, comment.story_id); + const story = await retrieveStory(mongo, tenant.id, comment.storyID); if (!story) { // TODO: (wyattjoh) return better error. throw new Error("story referenced does not exist"); @@ -155,7 +154,7 @@ export async function edit( let editedComment = await editComment(mongo, tenant.id, { id: input.id, - author_id: author.id, + authorID: author.id, body: input.body, status, metadata, @@ -173,7 +172,7 @@ export async function edit( } if (actions.length > 0) { - // The actions coming from the moderation phases didn't know the item_id + // The actions coming from the moderation phases didn't know the commentID // at the time, and we didn't want the repetitive nature of adding the // item_type each time, so this mapping function adds them! const inputs = actions.map( @@ -181,11 +180,11 @@ export async function edit( ...action, // Strict null check seems to have failed here... Null checking was done // above where we errored if the comment was falsely. - item_id: comment!.id, - item_type: ACTION_ITEM_TYPE.COMMENTS, + commentID: comment!.id, + commentRevisionID: getLatestRevision(comment!).id, // Store the Story ID on the action. - root_item_id: story.id, + storyID: story.id, }) ); diff --git a/src/core/server/services/comments/moderation/index.spec.ts b/src/core/server/services/comments/moderation/index.spec.ts index 3adcccfd2..3a641fc3b 100644 --- a/src/core/server/services/comments/moderation/index.spec.ts +++ b/src/core/server/services/comments/moderation/index.spec.ts @@ -2,7 +2,7 @@ import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ACTION_TYPE } from "talk-server/models/action"; +import { ACTION_TYPE } from "talk-server/models/action/comment"; import { compose, ModerationPhaseContext, @@ -51,11 +51,13 @@ describe("compose", () => { const flags = [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, }, { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, }, ]; @@ -71,7 +73,8 @@ describe("compose", () => { () => ({ actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, }, ], @@ -85,7 +88,7 @@ describe("compose", () => { } expect(final.actions).not.toContainEqual({ - action_type: ACTION_TYPE.FLAG, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, }); }); diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index 01c6a8c5a..f2b729dc4 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -1,7 +1,7 @@ import { Omit, Promiseable } from "talk-common/types"; import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; -import { CreateActionInput } from "talk-server/models/action"; -import { Comment } from "talk-server/models/comment"; +import { CreateActionInput } from "talk-server/models/action/comment"; +import { EditCommentInput } from "talk-server/models/comment"; import { Story } from "talk-server/models/story"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; @@ -9,7 +9,10 @@ import { Request } from "talk-server/types/express"; import { moderationPhases } from "./phases"; -export type ModerationAction = Omit; +export type ModerationAction = Omit< + CreateActionInput, + "commentID" | "commentRevisionID" +>; export interface PhaseResult { actions: ModerationAction[]; @@ -20,7 +23,7 @@ export interface PhaseResult { export interface ModerationPhaseContext { story: Story; tenant: Tenant; - comment: Partial; + comment: Partial; author: User; req?: Request; } diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts index 56dfc9269..1b61990f1 100644 --- a/src/core/server/services/comments/moderation/phases/commentLength.ts +++ b/src/core/server/services/comments/moderation/phases/commentLength.ts @@ -5,7 +5,7 @@ import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ACTION_TYPE } from "talk-server/models/action"; +import { ACTION_TYPE } from "talk-server/models/action/comment"; import { ModerationSettings } from "talk-server/models/settings"; import { IntermediateModerationPhase, @@ -50,7 +50,8 @@ export const commentLength: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.REJECTED, actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, metadata: { count: length, diff --git a/src/core/server/services/comments/moderation/phases/karma.ts b/src/core/server/services/comments/moderation/phases/karma.ts index 91ffb069f..8a1e15734 100755 --- a/src/core/server/services/comments/moderation/phases/karma.ts +++ b/src/core/server/services/comments/moderation/phases/karma.ts @@ -2,7 +2,7 @@ import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ACTION_TYPE } from "talk-server/models/action"; +import { ACTION_TYPE } from "talk-server/models/action/comment"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -33,7 +33,8 @@ export const karma: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, metadata: { trust: getCommentTrustScore(author), diff --git a/src/core/server/services/comments/moderation/phases/links.ts b/src/core/server/services/comments/moderation/phases/links.ts index 2388f2ea7..13486d9ee 100755 --- a/src/core/server/services/comments/moderation/phases/links.ts +++ b/src/core/server/services/comments/moderation/phases/links.ts @@ -5,7 +5,7 @@ import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ACTION_TYPE } from "talk-server/models/action"; +import { ACTION_TYPE } from "talk-server/models/action/comment"; import { ModerationSettings } from "talk-server/models/settings"; import { IntermediateModerationPhase, @@ -39,7 +39,8 @@ export const links: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, metadata: { links: comment.body, diff --git a/src/core/server/services/comments/moderation/phases/spam.ts b/src/core/server/services/comments/moderation/phases/spam.ts index d3ee2b008..90aac04c1 100644 --- a/src/core/server/services/comments/moderation/phases/spam.ts +++ b/src/core/server/services/comments/moderation/phases/spam.ts @@ -5,7 +5,7 @@ import { GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; import logger from "talk-server/logger"; -import { ACTION_TYPE } from "talk-server/models/action"; +import { ACTION_TYPE } from "talk-server/models/action/comment"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -20,29 +20,31 @@ export const spam: IntermediateModerationPhase = async ({ }): Promise => { const integration = tenant.integrations.akismet; + const log = logger.child({ + tenantID: tenant.id, + }); + // We can only check for spam if this comment originated from a graphql // request via an HTTP call. if (!req) { - logger.debug({ tenant_id: tenant.id }, "request was not available"); + log.debug("request was not available"); return; } if (!integration.enabled) { - logger.debug({ tenant_id: tenant.id }, "akismet integration was disabled"); + log.debug("akismet integration was disabled"); return; } if (!integration.key) { - logger.error( - { tenant_id: tenant.id }, + log.error( "akismet integration was enabled but the key configuration was missing" ); return; } if (!integration.site) { - logger.error( - { tenant_id: tenant.id }, + log.error( "akismet integration was enabled but the site configuration was missing" ); return; @@ -62,33 +64,24 @@ export const spam: IntermediateModerationPhase = async ({ // Grab the properties we need. const userIP = req.ip; if (!userIP) { - logger.debug( - { tenant_id: tenant.id }, - "request did not contain ip address, aborting spam check" - ); + log.debug("request did not contain ip address, aborting spam check"); return; } const userAgent = req.get("User-Agent"); if (!userAgent || userAgent.length === 0) { - logger.debug( - { tenant_id: tenant.id }, - "request did not contain User-Agent header, aborting spam check" - ); + log.debug("request did not contain User-Agent header, aborting spam check"); return; } const referrer = req.get("Referrer"); if (!referrer || referrer.length === 0) { - logger.debug( - { tenant_id: tenant.id }, - "request did not contain Referrer header, aborting spam check" - ); + log.debug("request did not contain Referrer header, aborting spam check"); return; } try { - logger.trace({ tenant_id: tenant.id }, "checking comment for spam"); + log.trace("checking comment for spam"); // Check the comment for spam. const isSpam = await client.checkSpam({ @@ -102,15 +95,13 @@ export const spam: IntermediateModerationPhase = async ({ is_test: false, }); if (isSpam) { - logger.trace( - { tenant_id: tenant.id, is_spam: isSpam }, - "comment contained spam" - ); + log.trace({ isSpam }, "comment contained spam"); return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, }, ], @@ -121,14 +112,8 @@ export const spam: IntermediateModerationPhase = async ({ }; } - logger.trace( - { tenant_id: tenant.id, is_spam: isSpam }, - "comment did not contain spam" - ); + log.trace({ isSpam }, "comment did not contain spam"); } catch (err) { - logger.error( - { tenant_id: tenant.id, err }, - "could not determine if comment contained spam" - ); + log.error({ err }, "could not determine if comment contained spam"); } }; diff --git a/src/core/server/services/comments/moderation/phases/storyClosed.spec.ts b/src/core/server/services/comments/moderation/phases/storyClosed.spec.ts index b1410aacd..aa20ba2fc 100644 --- a/src/core/server/services/comments/moderation/phases/storyClosed.spec.ts +++ b/src/core/server/services/comments/moderation/phases/storyClosed.spec.ts @@ -26,7 +26,7 @@ describe("storyClosed", () => { expect(() => storyClosed({ - story: { created_at: new Date() } as Story, + story: { createdAt: new Date() } as Story, tenant: { autoCloseStream: true, closedTimeout: -6000 } as Tenant, comment: {} as Comment, author: {} as User, diff --git a/src/core/server/services/comments/moderation/phases/storyClosed.ts b/src/core/server/services/comments/moderation/phases/storyClosed.ts index d08bdf72b..a51b44d2d 100644 --- a/src/core/server/services/comments/moderation/phases/storyClosed.ts +++ b/src/core/server/services/comments/moderation/phases/storyClosed.ts @@ -17,7 +17,7 @@ export const storyClosed: IntermediateModerationPhase = ({ story.closedAt !== false && tenant.autoCloseStream && tenant.closedTimeout && - story.created_at.valueOf() + tenant.closedTimeout <= Date.now() + story.createdAt.valueOf() + tenant.closedTimeout <= Date.now() ) { // TODO: (wyattjoh) return better error. throw new Error("story is currently closed for commenting"); diff --git a/src/core/server/services/comments/moderation/phases/toxic.ts b/src/core/server/services/comments/moderation/phases/toxic.ts index ec3a9f6ae..431a8d42c 100644 --- a/src/core/server/services/comments/moderation/phases/toxic.ts +++ b/src/core/server/services/comments/moderation/phases/toxic.ts @@ -9,7 +9,7 @@ import { GQLPerspectiveExternalIntegration, } from "talk-server/graph/tenant/schema/__generated__/types"; import logger from "talk-server/logger"; -import { ACTION_TYPE } from "talk-server/models/action"; +import { ACTION_TYPE } from "talk-server/models/action/comment"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -23,21 +23,19 @@ export const toxic: IntermediateModerationPhase = async ({ return; } + const log = logger.child({ tenantID: tenant.id }); + const integration = tenant.integrations.perspective; if (!integration.enabled) { // The Toxic comment plugin is not enabled. - logger.debug( - { tenant_id: tenant.id }, - "perspective integration was disabled" - ); + log.debug("perspective integration was disabled"); return; } if (!integration.key) { // The Toxic comment requires a key in order to communicate with the API. - logger.error( - { tenant_id: tenant.id }, + log.error( "perspective integration was enabled but the key configuration was missing" ); return; @@ -48,8 +46,8 @@ export const toxic: IntermediateModerationPhase = async ({ // TODO: (wyattjoh) replace hardcoded default with config. endpoint = "https://commentanalyzer.googleapis.com/v1alpha1"; - logger.trace( - { tenant_id: tenant.id, endpoint }, + log.trace( + { endpoint }, "endpoint missing in integration settings, using defaults" ); } @@ -59,8 +57,8 @@ export const toxic: IntermediateModerationPhase = async ({ // TODO: (wyattjoh) replace hardcoded default with config. threshold = 0.8; - logger.trace( - { tenant_id: tenant.id, threshold }, + log.trace( + { threshold }, "threshold missing in integration settings, using defaults" ); } @@ -69,8 +67,8 @@ export const toxic: IntermediateModerationPhase = async ({ if (isNil(doNotStore)) { doNotStore = true; - logger.trace( - { tenant_id: tenant.id, do_not_store: doNotStore }, + log.trace( + { doNotStore }, "doNotStore missing in integration settings, using defaults" ); } @@ -79,7 +77,7 @@ export const toxic: IntermediateModerationPhase = async ({ const timeout = ms("300ms"); try { - logger.trace({ tenant_id: tenant.id }, "checking comment toxicity"); + logger.trace("checking comment toxicity"); // Call into the Toxic comment API. const scores = await getScores( @@ -95,15 +93,13 @@ export const toxic: IntermediateModerationPhase = async ({ const score = scores.SEVERE_TOXICITY.summaryScore; const isToxic = score > threshold; if (isToxic) { - logger.trace( - { tenant_id: tenant.id, score, is_toxic: isToxic, threshold }, - "comment was toxic" - ); + log.trace({ score, isToxic, threshold }, "comment was toxic"); return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, }, ], @@ -114,15 +110,9 @@ export const toxic: IntermediateModerationPhase = async ({ }; } - logger.trace( - { tenant_id: tenant.id, score, is_toxic: isToxic, threshold }, - "comment was not toxic" - ); + log.trace({ score, isToxic, threshold }, "comment was not toxic"); } catch (err) { - logger.error( - { tenant_id: tenant.id, err }, - "could not determine comment toxicity" - ); + log.error({ err }, "could not determine comment toxicity"); } }; diff --git a/src/core/server/services/comments/moderation/phases/wordList.ts b/src/core/server/services/comments/moderation/phases/wordList.ts index c35458d15..30ffea763 100755 --- a/src/core/server/services/comments/moderation/phases/wordList.ts +++ b/src/core/server/services/comments/moderation/phases/wordList.ts @@ -2,7 +2,7 @@ import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ACTION_TYPE } from "talk-server/models/action"; +import { ACTION_TYPE } from "talk-server/models/action/comment"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -29,7 +29,8 @@ export const wordList: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.REJECTED, actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, }, ], @@ -46,7 +47,8 @@ export const wordList: IntermediateModerationPhase = ({ return { actions: [ { - action_type: ACTION_TYPE.FLAG, + userID: null, + actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, }, ], diff --git a/src/core/server/services/comments/moderation/wordList.ts b/src/core/server/services/comments/moderation/wordList.ts index 47f50d09e..f4a3d7e5d 100644 --- a/src/core/server/services/comments/moderation/wordList.ts +++ b/src/core/server/services/comments/moderation/wordList.ts @@ -1,8 +1,7 @@ import { memoize } from "lodash"; -// TODO: reintroduce this when we have https://github.com/DefinitelyTyped/DefinitelyTyped/pull/30035 merged -// // Replace `memoize.Cache`. -// memoize.Cache = WeakMap; +// Replace `memoize.Cache`. +memoize.Cache = WeakMap; /** * Escape string for special regular expression characters. diff --git a/src/core/server/services/moderation/index.ts b/src/core/server/services/moderation/index.ts new file mode 100644 index 000000000..185abe372 --- /dev/null +++ b/src/core/server/services/moderation/index.ts @@ -0,0 +1,81 @@ +import { Db } from "mongodb"; +import { Omit } from "talk-common/types"; +import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; +import logger from "talk-server/logger"; +import { + createCommentModerationAction, + CreateCommentModerationActionInput, +} from "talk-server/models/action/moderation/comment"; +import { updateCommentStatus } from "talk-server/models/comment"; +import { updateCommentStatusCount } from "talk-server/models/story"; +import { Tenant } from "talk-server/models/tenant"; + +export type Moderate = Omit; + +const moderate = (status: GQLCOMMENT_STATUS) => async ( + mongo: Db, + tenant: Tenant, + input: Moderate +) => { + // TODO: wrap these operations in a transaction? + + // Create the logger. + const log = logger.child({ + ...input, + tenantID: tenant.id, + newStatus: status, + }); + + // Update the Comment's status. + const result = await updateCommentStatus( + mongo, + tenant.id, + input.commentID, + input.commentRevisionID, + status + ); + if (!result) { + // TODO: wrap in better error? + throw new Error("specified comment not found"); + } + + log.trace("updated comment status"); + + // Create the moderation action in the audit log. + const action = await createCommentModerationAction(mongo, tenant.id, { + ...input, + status, + }); + if (!action) { + // TODO: wrap in better error? + throw new Error("could not create moderation action"); + } + + log.trace( + { commentModerationActionID: action.id }, + "created the moderation action" + ); + + // Update the story comment counts. + const story = await updateCommentStatusCount( + mongo, + tenant.id, + result.comment.storyID, + { + [result.oldStatus]: -1, + [status]: 1, + } + ); + if (!story) { + // TODO: wrap in better error? + throw new Error("specified story not found"); + } + + log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts"); + + return result.comment; +}; + +export const accept = moderate(GQLCOMMENT_STATUS.ACCEPTED); + +export const reject = moderate(GQLCOMMENT_STATUS.REJECTED); diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts index f30f15255..faa5e8d90 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -10,10 +10,10 @@ import { import logger from "talk-server/logger"; import { countTotalActionCounts, - mergeActionCounts, - mergeManyRootActions, - removeRootActions, -} from "talk-server/models/action"; + mergeCommentActionCounts, + mergeManyStoryActions, + removeStoryActions, +} from "talk-server/models/action/comment"; import { mergeManyCommentStories, removeStoryComments, @@ -123,8 +123,8 @@ export async function remove( ) { // Create a logger for this function. const log = logger.child({ - story_id: storyID, - include_comments: includeComments, + storyID, + includeComments, }); log.debug("starting to remove story"); @@ -138,32 +138,24 @@ export async function remove( } if (includeComments) { - let removedCount: number | undefined; - // Remove the actions associated with the comments we just removed. - ({ deletedCount: removedCount } = await removeRootActions( + const { deletedCount: removedActions } = await removeStoryActions( mongo, tenant.id, story.id - )); - - log.debug( - { removed_actions: removedCount }, - "removed actions while deleting story" ); + log.debug({ removedActions }, "removed actions while deleting story"); + // Remove the comments for the story. - ({ deletedCount: removedCount } = await removeStoryComments( + const { deletedCount: removedComments } = await removeStoryComments( mongo, tenant.id, story.id - )); - - log.debug( - { removed_comments: removedCount }, - "removed comments while deleting story" ); - } else if (calculateTotalCommentCount(story.comment_counts) > 0) { + + log.debug({ removedComments }, "removed comments while deleting story"); + } else if (calculateTotalCommentCount(story.commentCounts) > 0) { log.warn( "attempted to remove story that has linked comments without consent for deleting comments" ); @@ -196,7 +188,7 @@ export async function create( // Ensure that the given URL is allowed. if (!isURLPermitted(tenant, storyURL)) { logger.warn( - { story_url: storyURL, tenant_domains: tenant.domains }, + { storyURL, tenantDomains: tenant.domains }, "provided story url was not in the list of permitted tenant domains, story not created" ); return null; @@ -224,7 +216,7 @@ export async function update( // Ensure that the given URL is allowed. if (input.url && !isURLPermitted(tenant, input.url)) { logger.warn( - { story_url: input.url, tenant_domains: tenant.domains }, + { storyURL: input.url, tenantDomains: tenant.domains }, "provided story url was not in the list of permitted tenant domains, story not updated" ); return null; @@ -241,8 +233,8 @@ export async function merge( ) { // Create a logger for this operation. const log = logger.child({ - destination_id: destinationID, - source_ids: sourceIDs, + destinationID, + sourceIDs, }); if (sourceIDs.length === 0) { @@ -259,7 +251,7 @@ export async function merge( zip(storyIDs, stories).some(([storyID, story]) => { if (!story) { log.warn( - { story_id: storyID }, + { storyID }, "story that was going to be merged was not found" ); return true; @@ -271,36 +263,28 @@ export async function merge( return null; } - let updatedCount: number | undefined; - // Move all the comment's from the source stories over to the destination // story. - ({ modifiedCount: updatedCount } = await mergeManyCommentStories( + const { modifiedCount: updatedComments } = await mergeManyCommentStories( mongo, tenant.id, destinationID, sourceIDs - )); - - log.debug( - { updated_comments: updatedCount }, - "updated comments while merging stories" ); + log.debug({ updatedComments }, "updated comments while merging stories"); + // Update all the action's that referenced the old story to reference the new // story. - ({ modifiedCount: updatedCount } = await mergeManyRootActions( + const { modifiedCount: updatedActions } = await mergeManyStoryActions( mongo, tenant.id, destinationID, sourceIDs - )); - - log.debug( - { updated_actions: updatedCount }, - "updated actions while merging stories" ); + log.debug({ updatedActions }, "updated actions while merging stories"); + // Merge the comment and action counts for all the source stories. const [, ...sourceStories] = stories; @@ -311,14 +295,16 @@ export async function merge( mergeCommentStatusCount( // We perform the type assertion here because above, we already verified // that none of the stories are null. - (sourceStories as Story[]).map(({ comment_counts }) => comment_counts) + (sourceStories as Story[]).map(({ commentCounts }) => commentCounts) ) ); - const mergedActionCounts = mergeActionCounts( + const mergedActionCounts = mergeCommentActionCounts( // We perform the type assertion here because above, we already verified // that none of the stories are null. - (sourceStories as Story[]).map(({ action_counts }) => action_counts) + (sourceStories as Story[]).map( + ({ commentActionCounts }) => commentActionCounts + ) ); if (countTotalActionCounts(mergedActionCounts) > 0) { destinationStory = await updateStoryActionCounts( @@ -335,13 +321,13 @@ export async function merge( } log.debug( - { comment_counts: destinationStory.comment_counts }, + { commentCounts: destinationStory.commentCounts }, "updated destination story with new comment counts" ); const { deletedCount } = await removeStories(mongo, tenant.id, sourceIDs); - log.debug({ deleted_stories: deletedCount }, "deleted source stories"); + log.debug({ deletedStories: deletedCount }, "deleted source stories"); // Return the story that had the other stories merged into. return destinationStory; diff --git a/src/core/server/services/tenant/cache/index.ts b/src/core/server/services/tenant/cache/index.ts index 5bcbd3432..03a826e9a 100644 --- a/src/core/server/services/tenant/cache/index.ts +++ b/src/core/server/services/tenant/cache/index.ts @@ -195,7 +195,7 @@ export default class TenantCache { return; } - logger.debug({ tenant_id: tenant.id }, "received updated tenant"); + logger.debug({ tenantID: tenant.id }, "received updated tenant"); // Update the tenant cache. this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant); @@ -261,7 +261,7 @@ export default class TenantCache { JSON.stringify(message) ); - logger.debug({ tenant_id: tenant.id, subscribers }, "updated tenant"); + logger.debug({ tenantID: tenant.id, subscribers }, "updated tenant"); // Publish the event for the connected listeners. this.emitter.emit(EMITTER_EVENT_NAME, tenant); diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index f19df2675..ca9efae78 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -64,7 +64,7 @@ export async function install( await cache.update(redis, tenant); logger.info( - { tenant_id: tenant.id, tenant_domain: tenant.domain }, + { tenantID: tenant.id, tenantDomain: tenant.domain }, "a tenant has been installed" );