From 413f3e2f1e3788eb487920a113d0ebeb0bdb26b7 Mon Sep 17 00:00:00 2001 From: Vinh Date: Fri, 21 Jun 2019 19:01:07 +0200 Subject: [PATCH] [CORL-166] Live Updates on Mod Queues (#2368) * feat: client implementation of subscriptions and modqueue live counts * fix: unit tests * feat: live status update in moderation * feat: live update of new comments in moderation * chore: View New instead of View More * feat: fade in transition for new comments * chore: turn websocket proxy back on * feat: initial server impl * fix: make it work :-) * fix: add box shadow * chore: make test subscriptions only support 1 top level field following the spec * fix: linting * feat: support clientID * fix: linting * feat: support commentStatusUpdated subscription * fix: disabled styles for approve and reject button * feat: show moderated by system and update flags * feat: support metrics recording on websocket connections * fix: handle when same comment enters but leaves again --- config/webpackDevServer.config.ts | 5 + package-lock.json | 65 +- package.json | 8 +- .../admin/helpers/getQueueConnection.ts | 26 +- src/core/client/admin/local/local.graphql | 13 + .../admin/mutations/ApproveCommentMutation.ts | 17 +- .../admin/mutations/RejectCommentMutation.ts | 15 +- .../client/admin/routes/Moderate/Moderate.css | 2 +- .../Moderate/ModerateCard/ApproveButton.css | 2 +- .../ModerateCard/FadeInTransition.css | 8 + .../ModerateCard/FadeInTransition.tsx | 31 + .../ModerateCard/FeatureCommentMutation.ts | 8 +- .../Moderate/ModerateCard/ModerateCard.css | 26 +- .../ModerateCard/ModerateCard.spec.tsx | 1 + .../Moderate/ModerateCard/ModerateCard.tsx | 3 + .../ModerateCard/ModerateCardContainer.tsx | 90 +- .../ModerateCard/ModeratedByContainer.css | 20 + .../ModerateCard/ModeratedByContainer.tsx | 71 + .../Moderate/ModerateCard/RejectButton.css | 2 +- ...derateCountsCommentEnteredSubscription.tsx | 37 + .../ModerateCountsCommentLeftSubscription.tsx | 40 + .../ModerateNavigationContainer.tsx | 33 +- .../ModerationCountsSubscription.tsx | 61 + .../ModerateNavigation/changeQueueCount.ts | 23 + .../admin/routes/Moderate/Queue/Queue.css | 11 + .../routes/Moderate/Queue/Queue.spec.tsx | 8 +- .../admin/routes/Moderate/Queue/Queue.tsx | 28 +- .../Queue/QueueCommentEnteredSubscription.tsx | 68 + .../Queue/QueueCommentLeftSubscription.tsx | 72 + .../routes/Moderate/Queue/QueueRoute.tsx | 287 +- .../Moderate/Queue/QueueViewNewMutation.tsx | 35 + .../Moderate/Queue/RejectedQueueRoute.tsx | 10 +- .../SingleModerate/SingleModerateRoute.tsx | 72 +- .../SingleModerateSubscription.tsx | 44 + src/core/client/admin/test/fixtures.ts | 34 +- .../__snapshots__/moderate.spec.tsx.snap | 3299 ----------------- .../__snapshots__/regularQueue.spec.tsx.snap | 1486 ++++++++ .../__snapshots__/rejectedQueue.spec.tsx.snap | 849 +++++ .../__snapshots__/searchBar.spec.tsx.snap | 255 ++ .../__snapshots__/singleComment.spec.tsx.snap | 586 +++ .../__snapshots__/tabBar.spec.tsx.snap | 127 + .../admin/test/moderate/liveCounts.spec.tsx | 123 + .../admin/test/moderate/moderate.spec.tsx | 1158 ------ .../admin/test/moderate/regularQueue.spec.tsx | 554 +++ .../regularQueueLiveNewComments.spec.tsx | 139 + .../moderate/regularQueueLiveStatus.spec.tsx | 126 + .../test/moderate/rejectedQueue.spec.tsx | 314 ++ .../admin/test/moderate/searchBar.spec.tsx | 264 ++ .../test/moderate/singleComment.spec.tsx | 189 + .../moderate/singleCommentLiveStatus.spec.tsx | 99 + .../admin/test/moderate/singleStory.spec.tsx | 69 + .../admin/test/moderate/tabBar.spec.tsx | 74 + .../framework/lib/bootstrap/createManaged.tsx | 83 +- .../lib/network/clientIDMiddleware.ts | 20 + .../createManagedSubscriptionClient.ts | 179 + .../framework/lib/network/createNetwork.ts | 82 +- .../client/framework/lib/network/index.ts | 4 + src/core/client/framework/lib/relay/index.ts | 8 + .../framework/lib/relay/subscription.tsx | 98 + src/core/client/framework/lib/rest.ts | 15 +- src/core/client/framework/schema/custom.ts | 4 + .../testHelpers/createRelayEnvironment.ts | 62 +- .../testHelpers/createSubscriptionHandler.ts | 92 + .../testHelpers/createTestRenderer.tsx | 11 +- .../client/framework/testHelpers/matchText.ts | 30 +- src/core/common/constants.ts | 18 + .../app/handlers/api/account/confirm.ts | 6 +- .../app/handlers/api/auth/local/forgot.ts | 6 +- src/core/server/app/handlers/api/graphql.ts | 72 +- src/core/server/app/helpers/hostname.ts | 21 + src/core/server/app/index.ts | 11 +- .../server/app/middleware/graphql/index.ts | 43 +- src/core/server/app/middleware/metrics.ts | 21 +- .../server/app/middleware/passport/index.ts | 4 +- .../app/middleware/passport/strategies/jwt.ts | 74 +- src/core/server/app/router/index.ts | 8 +- src/core/server/errors/index.ts | 4 +- src/core/server/graph/common/context.ts | 19 + .../common/extensions/LoggerExtension.ts | 26 +- .../common/extensions/MetricsExtension.ts | 19 +- .../graph/common/subscriptions/pubsub.ts | 12 + src/core/server/graph/tenant/context.ts | 28 +- src/core/server/graph/tenant/loaders/Auth.ts | 10 +- .../server/graph/tenant/loaders/Comments.ts | 44 +- .../server/graph/tenant/loaders/Stories.ts | 32 +- src/core/server/graph/tenant/loaders/Users.ts | 21 +- src/core/server/graph/tenant/loaders/util.ts | 15 +- .../server/graph/tenant/mutators/Actions.ts | 4 +- .../server/graph/tenant/mutators/Comments.ts | 7 +- .../commentEnteredModerationQueue.ts | 38 + .../commentLeftModerationQueue.ts | 37 + .../Subscription/commentStatusUpdated.ts | 40 + .../tenant/resolvers/Subscription/helpers.ts | 108 + .../tenant/resolvers/Subscription/index.ts | 11 + .../tenant/resolvers/Subscription/types.ts | 35 + .../server/graph/tenant/resolvers/index.ts | 2 + .../server/graph/tenant/schema/schema.graphql | 118 + .../graph/tenant/subscriptions/publisher.ts | 28 + .../graph/tenant/subscriptions/server.ts | 222 ++ src/core/server/index.ts | 57 +- src/core/server/models/user/helpers.ts | 2 +- src/core/server/services/comments/actions.ts | 25 +- src/core/server/services/comments/index.ts | 78 +- .../services/comments/moderation/index.ts | 51 +- src/core/server/services/events/comments.ts | 92 + src/core/server/services/events/index.ts | 1 + src/core/server/services/jwt/index.spec.ts | 14 +- src/core/server/services/jwt/index.ts | 5 +- src/core/server/services/metrics/index.ts | 44 + src/locales/en-US/admin.ftl | 8 + src/types/permit.d.ts | 31 - 111 files changed, 8230 insertions(+), 5017 deletions(-) create mode 100644 src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.css create mode 100644 src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.tsx create mode 100644 src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.css create mode 100644 src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.tsx create mode 100644 src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx create mode 100644 src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx create mode 100644 src/core/client/admin/routes/Moderate/ModerateNavigation/ModerationCountsSubscription.tsx create mode 100644 src/core/client/admin/routes/Moderate/ModerateNavigation/changeQueueCount.ts create mode 100644 src/core/client/admin/routes/Moderate/Queue/QueueCommentEnteredSubscription.tsx create mode 100644 src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx create mode 100644 src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx create mode 100644 src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateSubscription.tsx delete mode 100644 src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap create mode 100644 src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap create mode 100644 src/core/client/admin/test/moderate/__snapshots__/rejectedQueue.spec.tsx.snap create mode 100644 src/core/client/admin/test/moderate/__snapshots__/searchBar.spec.tsx.snap create mode 100644 src/core/client/admin/test/moderate/__snapshots__/singleComment.spec.tsx.snap create mode 100644 src/core/client/admin/test/moderate/__snapshots__/tabBar.spec.tsx.snap create mode 100644 src/core/client/admin/test/moderate/liveCounts.spec.tsx delete mode 100644 src/core/client/admin/test/moderate/moderate.spec.tsx create mode 100644 src/core/client/admin/test/moderate/regularQueue.spec.tsx create mode 100644 src/core/client/admin/test/moderate/regularQueueLiveNewComments.spec.tsx create mode 100644 src/core/client/admin/test/moderate/regularQueueLiveStatus.spec.tsx create mode 100644 src/core/client/admin/test/moderate/rejectedQueue.spec.tsx create mode 100644 src/core/client/admin/test/moderate/searchBar.spec.tsx create mode 100644 src/core/client/admin/test/moderate/singleComment.spec.tsx create mode 100644 src/core/client/admin/test/moderate/singleCommentLiveStatus.spec.tsx create mode 100644 src/core/client/admin/test/moderate/singleStory.spec.tsx create mode 100644 src/core/client/admin/test/moderate/tabBar.spec.tsx create mode 100644 src/core/client/framework/lib/network/clientIDMiddleware.ts create mode 100644 src/core/client/framework/lib/network/createManagedSubscriptionClient.ts create mode 100644 src/core/client/framework/lib/relay/subscription.tsx create mode 100644 src/core/client/framework/testHelpers/createSubscriptionHandler.ts create mode 100644 src/core/common/constants.ts create mode 100644 src/core/server/app/helpers/hostname.ts create mode 100644 src/core/server/graph/common/subscriptions/pubsub.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/helpers.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/index.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/types.ts create mode 100644 src/core/server/graph/tenant/subscriptions/publisher.ts create mode 100644 src/core/server/graph/tenant/subscriptions/server.ts create mode 100644 src/core/server/services/events/comments.ts create mode 100644 src/core/server/services/events/index.ts create mode 100644 src/core/server/services/metrics/index.ts delete mode 100644 src/types/permit.d.ts diff --git a/config/webpackDevServer.config.ts b/config/webpackDevServer.config.ts index 8e2c97982..784be01b2 100644 --- a/config/webpackDevServer.config.ts +++ b/config/webpackDevServer.config.ts @@ -79,6 +79,11 @@ export default function({ public: allowedHost, index: "embed.html", proxy: { + // Proxy websocket connections. + "/api/graphql/live": { + target: `ws://localhost:${serverPort}`, + ws: true, + }, // Proxy to the graphql server. "/api": { target: `http://localhost:${serverPort}`, diff --git a/package-lock.json b/package-lock.json index 3b675c63d..df6f2d980 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4018,6 +4018,15 @@ "@types/passport": "*" } }, + "@types/permit": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@types/permit/-/permit-0.2.1.tgz", + "integrity": "sha512-32fn5mTq0AZHyd61Z+mzx6+LiPojNeC/O2B8w47rfNYqAUU+FZvDsCjCzz7WnRJ/MrE1VtL3rupodTE8I8cLHw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/prop-types": { "version": "15.5.8", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz", @@ -4885,6 +4894,16 @@ "lodash": "^4.17.10", "subscriptions-transport-ws": "^0.9.11", "ws": "^5.2.0" + }, + "dependencies": { + "graphql-subscriptions": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz", + "integrity": "sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==", + "requires": { + "iterall": "^1.2.1" + } + } } }, "apollo-server-env": { @@ -4938,6 +4957,14 @@ "type-is": "~1.6.16" } }, + "graphql-subscriptions": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz", + "integrity": "sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==", + "requires": { + "iterall": "^1.2.1" + } + }, "iconv-lite": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", @@ -16093,6 +16120,15 @@ } } }, + "graphql-redis-subscriptions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/graphql-redis-subscriptions/-/graphql-redis-subscriptions-2.1.0.tgz", + "integrity": "sha512-edur8YlwIsjk9K1Ao8vgEQkNKvt11FCTlOIFfxMKYIHOVv4zMvDsv7fs282LxMJBJGDCDDcdc1c5iKj0BjXO+Q==", + "requires": { + "ioredis": "^4.6.3", + "iterall": "^1.2.2" + } + }, "graphql-request": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-1.6.0.tgz", @@ -16174,9 +16210,9 @@ } }, "graphql-subscriptions": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz", - "integrity": "sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-1.1.0.tgz", + "integrity": "sha512-6WzlBFC0lWmXJbIVE8OgFgXIP4RJi3OQgTPa0DVMsDXdpRDjTsM1K9wfl5HSYX7R87QAGlvcv2Y4BIZa/ItonA==", "requires": { "iterall": "^1.2.1" } @@ -28474,17 +28510,26 @@ "dev": true }, "react-transition-group": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.0.tgz", - "integrity": "sha512-qYB3JBF+9Y4sE4/Mg/9O6WFpdoYjeeYqx0AFb64PTazVy8RPMiE3A47CG9QmM4WJ/mzDiZYslV+Uly6O1Erlgw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", "dev": true, "requires": { - "dom-helpers": "^3.3.1", + "dom-helpers": "^3.4.0", "loose-envify": "^1.4.0", "prop-types": "^15.6.2", "react-lifecycles-compat": "^3.0.4" }, "dependencies": { + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -30938,9 +30983,9 @@ } }, "subscriptions-transport-ws": { - "version": "0.9.12", - "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.12.tgz", - "integrity": "sha512-57Ar8hjr/63fCx1kM3kyDr64FAPQITMguuFuTGgYVx2v1JOaPoTeZyTIenVPgv+7mDYt7E+h+Jyxvznb+UKVWw==", + "version": "0.9.16", + "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.16.tgz", + "integrity": "sha512-pQdoU7nC+EpStXnCfh/+ho0zE0Z+ma+i7xvj7bkXKb1dvYHSZxgRPaU6spRP+Bjzow67c/rRDoix5RT0uU9omw==", "requires": { "backo2": "^1.0.2", "eventemitter3": "^3.1.0", diff --git a/package.json b/package.json index 87cdf7b7c..027618dab 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,8 @@ "graphql-extensions": "^0.2.1", "graphql-fields": "^1.1.0", "graphql-playground-html": "^1.6.0", + "graphql-redis-subscriptions": "^2.1.0", + "graphql-subscriptions": "^1.1.0", "graphql-tools": "^3.0.5", "html-minifier": "^3.5.21", "html-to-text": "^4.0.0", @@ -139,8 +141,8 @@ "@coralproject/npm-run-all": "^4.1.5", "@coralproject/rte": "^0.10.15", "@intervolga/optimize-cssnano-plugin": "^1.0.6", - "@types/basic-auth": "^1.1.2", "@types/agent-base": "^4.2.0", + "@types/basic-auth": "^1.1.2", "@types/bcryptjs": "^2.4.1", "@types/bull": "^3.5.12", "@types/bunyan": "^1.8.4", @@ -187,6 +189,7 @@ "@types/passport-local": "^1.0.33", "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", + "@types/permit": "^0.2.1", "@types/prop-types": "^15.5.8", "@types/react": "^16.8.15", "@types/react-copy-to-clipboard": "^4.2.6", @@ -297,7 +300,7 @@ "react-responsive": "^7.0.0", "react-test-renderer": "^16.9.0-alpha.0", "react-timeago": "^4.1.9", - "react-transition-group": "^2.5.0", + "react-transition-group": "^2.9.0", "react-with-state-props": "^2.0.4", "recompose": "0.27.1", "relay-compiler": "^4.0.0", @@ -312,6 +315,7 @@ "sockjs-client": "^1.3.0", "strip-ansi": "^5.2.0", "style-loader": "^0.23.1", + "subscriptions-transport-ws": "^0.9.16", "terser-webpack-plugin": "^1.2.3", "thread-loader": "^2.1.2", "timekeeper": "^2.2.0", diff --git a/src/core/client/admin/helpers/getQueueConnection.ts b/src/core/client/admin/helpers/getQueueConnection.ts index f8191ff1f..19b905f1e 100644 --- a/src/core/client/admin/helpers/getQueueConnection.ts +++ b/src/core/client/admin/helpers/getQueueConnection.ts @@ -1,16 +1,24 @@ -import { ConnectionHandler, RecordSourceSelectorProxy } from "relay-runtime"; +import { + ConnectionHandler, + RecordProxy, + RecordSourceProxy, + RecordSourceSelectorProxy, +} from "relay-runtime"; -type Queue = "reported" | "pending" | "unmoderated" | "rejected"; +import { + GQLCOMMENT_STATUS, + GQLMODERATION_QUEUE_RL, +} from "coral-framework/schema"; export default function getQueueConnection( - store: RecordSourceSelectorProxy, - queue: Queue, - storyID?: string -) { + store: RecordSourceSelectorProxy | RecordSourceProxy, + queue: GQLMODERATION_QUEUE_RL | "REJECTED", + storyID?: string | null +): RecordProxy | null { const root = store.getRoot(); - if (queue === "rejected") { + if (queue === "REJECTED") { return ConnectionHandler.getConnection(root, "RejectedQueue_comments", { - status: "REJECTED", + status: GQLCOMMENT_STATUS.REJECTED, storyID, }); } @@ -19,7 +27,7 @@ export default function getQueueConnection( return null; } return ConnectionHandler.getConnection( - queuesRecord.getLinkedRecord(queue), + queuesRecord.getLinkedRecord(queue.toLowerCase()), "Queue_comments" ); } diff --git a/src/core/client/admin/local/local.graphql b/src/core/client/admin/local/local.graphql index 8a3193323..cfba71292 100644 --- a/src/core/client/admin/local/local.graphql +++ b/src/core/client/admin/local/local.graphql @@ -10,6 +10,19 @@ enum View { ADD_EMAIL_ADDRESS } +extend type Comment { + # If true then Comment status was live updated. + statusLiveUpdated: Boolean + + # If true then Comment came in live. + enteredLive: Boolean +} + +extend type CommentsConnection { + # Contains comment that came in live and is still behind the `View New` button. + viewNewEdges: [CommentEdge!] +} + type Local { network: Network! accessToken: String diff --git a/src/core/client/admin/mutations/ApproveCommentMutation.ts b/src/core/client/admin/mutations/ApproveCommentMutation.ts index 1ac3e0a89..3bdf2d6b7 100644 --- a/src/core/client/admin/mutations/ApproveCommentMutation.ts +++ b/src/core/client/admin/mutations/ApproveCommentMutation.ts @@ -27,6 +27,15 @@ const ApproveCommentMutation = createMutation( comment { id status + statusHistory(first: 1) { + edges { + node { + moderator { + username + } + } + } + } } moderationQueues(storyID: $storyID) { unmoderated { @@ -56,10 +65,10 @@ const ApproveCommentMutation = createMutation( }, updater: store => { const connections = [ - getQueueConnection(store, "reported", input.storyID), - getQueueConnection(store, "pending", input.storyID), - getQueueConnection(store, "unmoderated", input.storyID), - getQueueConnection(store, "rejected", input.storyID), + getQueueConnection(store, "REPORTED", input.storyID), + getQueueConnection(store, "PENDING", input.storyID), + getQueueConnection(store, "UNMODERATED", input.storyID), + getQueueConnection(store, "REJECTED", input.storyID), ].filter(c => c); connections.forEach(con => ConnectionHandler.deleteNode(con, input.commentID) diff --git a/src/core/client/admin/mutations/RejectCommentMutation.ts b/src/core/client/admin/mutations/RejectCommentMutation.ts index c9b4de730..2105aa27b 100644 --- a/src/core/client/admin/mutations/RejectCommentMutation.ts +++ b/src/core/client/admin/mutations/RejectCommentMutation.ts @@ -27,6 +27,15 @@ const RejectCommentMutation = createMutation( comment { id status + statusHistory(first: 1) { + edges { + node { + moderator { + username + } + } + } + } } moderationQueues(storyID: $storyID) { unmoderated { @@ -56,9 +65,9 @@ const RejectCommentMutation = createMutation( }, updater: store => { const connections = [ - getQueueConnection(store, "reported", input.storyID), - getQueueConnection(store, "pending", input.storyID), - getQueueConnection(store, "unmoderated", input.storyID), + getQueueConnection(store, "REPORTED", input.storyID), + getQueueConnection(store, "PENDING", input.storyID), + getQueueConnection(store, "UNMODERATED", input.storyID), ].filter(c => c); connections.forEach(con => ConnectionHandler.deleteNode(con, input.commentID) diff --git a/src/core/client/admin/routes/Moderate/Moderate.css b/src/core/client/admin/routes/Moderate/Moderate.css index 093f5dd04..072624e16 100644 --- a/src/core/client/admin/routes/Moderate/Moderate.css +++ b/src/core/client/admin/routes/Moderate/Moderate.css @@ -9,7 +9,7 @@ } .main { - margin: calc(2 * var(--mini-unit)) 0 calc(4 * var(--mini-unit)) 0; + margin: var(--spacing-5) 0 var(--spacing-7) 0; display: flex; justify-content: center; } diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ApproveButton.css b/src/core/client/admin/routes/Moderate/ModerateCard/ApproveButton.css index 7d0a16339..f861d641d 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/ApproveButton.css +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ApproveButton.css @@ -8,7 +8,7 @@ justify-content: center; align-items: center; color: var(--palette-success-main); - &:active { + &:not(:disabled):active { background-color: var(--palette-success-main); color: var(--palette-common-white); } diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.css b/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.css new file mode 100644 index 000000000..6654329a0 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.css @@ -0,0 +1,8 @@ +.appear { + opacity: 0; + pointer-events: none; +} +.appearActive { + opacity: 1; + transition: opacity 400ms; +} diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.tsx b/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.tsx new file mode 100644 index 000000000..353654f75 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.tsx @@ -0,0 +1,31 @@ +import React, { FunctionComponent } from "react"; +import { CSSTransition } from "react-transition-group"; + +import styles from "./FadeInTransition.css"; + +interface Props { + active: boolean; + children: React.ReactNode; +} + +const FadeInTransition: FunctionComponent = ({ children, active }) => { + if (!active) { + return <>{children}; + } + return ( + +
{children}
+
+ ); +}; +export default FadeInTransition; diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/FeatureCommentMutation.ts b/src/core/client/admin/routes/Moderate/ModerateCard/FeatureCommentMutation.ts index 64d820772..598e6f56e 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/FeatureCommentMutation.ts +++ b/src/core/client/admin/routes/Moderate/ModerateCard/FeatureCommentMutation.ts @@ -68,10 +68,10 @@ const FeatureCommentMutation = createMutation( }, updater: store => { const connections = [ - getQueueConnection(store, "reported", input.storyID), - getQueueConnection(store, "pending", input.storyID), - getQueueConnection(store, "unmoderated", input.storyID), - getQueueConnection(store, "rejected", input.storyID), + getQueueConnection(store, "PENDING", input.storyID), + getQueueConnection(store, "REPORTED", input.storyID), + getQueueConnection(store, "UNMODERATED", input.storyID), + getQueueConnection(store, "REJECTED", input.storyID), ].filter(c => c); connections.forEach(con => ConnectionHandler.deleteNode(con, input.commentID) diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.css b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.css index ed82f60ef..e75c41a5b 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.css +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.css @@ -11,7 +11,7 @@ } .content { - min-height: calc(4.5 * var(--mini-unit)); + min-height: calc(5.5 * var(--mini-unit)); } .mainContainer { @@ -47,11 +47,12 @@ } .root { - transition: background 100ms; + transition: background 100ms, box-shadow 100ms; } .dangling { background-color: var(--palette-grey-lightest); + box-shadow: none; } .link { @@ -81,3 +82,24 @@ letter-spacing: calc(0.2em / 12); color: var(--palette-text-primary); } + +.moderatedBy { + font-size: calc(12rem / var(--rem-base)); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-sans-serif); + line-height: 1; + letter-spacing: 0; + color: var(--palette-text-primary); + text-transform: uppercase; +} + +.moderatedByUsername { + font-size: calc(14rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-sans-serif); + line-height: 1; + letter-spacing: 0; + color: var(--palette-text-primary); + text-align: center; + padding-top: 1px; +} diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.spec.tsx b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.spec.tsx index a0e0b575e..5e3c351a7 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.spec.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.spec.tsx @@ -25,6 +25,7 @@ const baseProps: PropTypesOf = { onReject: noop, onFeature: noop, showStory: false, + moderatedBy: null, }; it("renders correctly", () => { diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.tsx b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.tsx index bf105a05d..0dec4b678 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCard.tsx @@ -25,6 +25,7 @@ interface Props { comment: PropTypesOf["comment"]; status: "approved" | "rejected" | "undecided"; featured: boolean; + moderatedBy: React.ReactNode | null; viewContextHref: string; suspectWords: ReadonlyArray; bannedWords: ReadonlyArray; @@ -63,6 +64,7 @@ const ModerateCard: FunctionComponent = ({ storyTitle, storyHref, onModerateStory, + moderatedBy, }) => ( = ({ disabled={status === "approved" || dangling} /> + {moderatedBy} diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx index 767b4aa06..301de1ba9 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx @@ -4,9 +4,10 @@ import { graphql } from "react-relay"; import { COMMENT_STATUS, - ModerateCardContainer_comment as CommentData, + ModerateCardContainer_comment, } from "coral-admin/__generated__/ModerateCardContainer_comment.graphql"; -import { ModerateCardContainer_settings as SettingsData } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql"; +import { ModerateCardContainer_settings } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql"; +import { ModerateCardContainer_viewer } from "coral-admin/__generated__/ModerateCardContainer_viewer.graphql"; import NotAvailable from "coral-admin/components/NotAvailable"; import { getModerationLink } from "coral-admin/helpers"; import { ApproveCommentMutation } from "coral-admin/mutations"; @@ -18,13 +19,16 @@ import { } from "coral-framework/lib/relay"; import { GQLTAG } from "coral-framework/schema"; +import FadeInTransition from "./FadeInTransition"; import FeatureCommentMutation from "./FeatureCommentMutation"; import ModerateCard from "./ModerateCard"; +import ModeratedByContainer from "./ModeratedByContainer"; import UnfeatureCommentMutation from "./UnfeatureCommentMutation"; interface Props { - comment: CommentData; - settings: SettingsData; + comment: ModerateCardContainer_comment; + viewer: ModerateCardContainer_viewer; + settings: ModerateCardContainer_settings; approveComment: MutationProp; rejectComment: MutationProp; featureComment: MutationProp; @@ -35,7 +39,7 @@ interface Props { showStoryInfo: boolean; } -function getStatus(comment: CommentData) { +function getStatus(comment: ModerateCardContainer_comment) { switch (comment.status) { case "APPROVED": return "approved"; @@ -46,7 +50,7 @@ function getStatus(comment: CommentData) { } } -function isFeatured(comment: CommentData) { +function isFeatured(comment: ModerateCardContainer_comment) { return comment.tags.some(t => t.code === GQLTAG.FEATURED); } @@ -102,33 +106,45 @@ class ModerateCardContainer extends React.Component { }; public render() { - const { comment, settings, danglingLogic, showStoryInfo } = this.props; + const { + comment, + settings, + danglingLogic, + showStoryInfo, + viewer, + } = this.props; + const dangling = danglingLogic(comment.status); return ( - - ) - } - storyHref={getModerationLink("default", comment.story.id)} - onModerateStory={this.handleModerateStory} - /> + + + } + showStory={showStoryInfo} + storyTitle={ + (comment.story.metadata && comment.story.metadata.title) || ( + + ) + } + storyHref={getModerationLink("default", comment.story.id)} + onModerateStory={this.handleModerateStory} + /> + ); } } @@ -140,12 +156,13 @@ const enhanced = withFragmentContainer({ author { username } + statusLiveUpdated createdAt body - status tags { code } + status revision { id } @@ -161,7 +178,9 @@ const enhanced = withFragmentContainer({ } } permalink + enteredLive ...MarkersContainer_comment + ...ModeratedByContainer_comment } `, settings: graphql` @@ -172,6 +191,11 @@ const enhanced = withFragmentContainer({ } } `, + viewer: graphql` + fragment ModerateCardContainer_viewer on User { + ...ModeratedByContainer_viewer + } + `, })( withRouter( withMutation(ApproveCommentMutation)( diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.css b/src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.css new file mode 100644 index 000000000..7decb5934 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.css @@ -0,0 +1,20 @@ +.moderatedBy { + font-size: calc(12rem / var(--rem-base)); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-sans-serif); + line-height: 1; + letter-spacing: 0; + color: var(--palette-text-primary); + text-transform: uppercase; +} + +.moderatedByUsername { + font-size: calc(14rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-sans-serif); + line-height: 1; + letter-spacing: 0; + color: var(--palette-text-primary); + text-align: center; + padding-top: 1px; +} diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.tsx new file mode 100644 index 000000000..8e87a02b5 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ModeratedByContainer.tsx @@ -0,0 +1,71 @@ +import { Localized } from "fluent-react/compat"; +import React from "react"; +import { graphql } from "react-relay"; + +import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql"; +import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql"; +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import styles from "./ModeratedByContainer.css"; + +interface Props { + viewer: ModeratedByContainer_viewer; + comment: ModeratedByContainer_comment; +} + +const ModeratedByContainer: React.FunctionComponent = ({ + comment, + viewer, +}) => { + let moderatedBy: React.ReactElement | null; + if (!comment.statusLiveUpdated || comment.statusHistory.edges.length === 0) { + moderatedBy = null; + } else if (comment.statusHistory.edges[0].node.moderator === null) { + moderatedBy = ( + System + ); + } else if (viewer.id === comment.statusHistory.edges[0].node.moderator.id) { + moderatedBy = null; + } else { + moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}; + } + + if (!moderatedBy) { + return null; + } + + return ( +
+ +
Moderated By
+
+
{moderatedBy}
+
+ ); +}; + +const enhanced = withFragmentContainer({ + comment: graphql` + fragment ModeratedByContainer_comment on Comment { + id + statusLiveUpdated + statusHistory(first: 1) { + edges { + node { + moderator { + id + username + } + } + } + } + } + `, + viewer: graphql` + fragment ModeratedByContainer_viewer on User { + id + } + `, +})(ModeratedByContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/RejectButton.css b/src/core/client/admin/routes/Moderate/ModerateCard/RejectButton.css index b66f03011..11ba1be06 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/RejectButton.css +++ b/src/core/client/admin/routes/Moderate/ModerateCard/RejectButton.css @@ -8,7 +8,7 @@ justify-content: center; align-items: center; color: var(--palette-error-main); - &:active { + &:not(:disabled):active { background-color: var(--palette-error-main); color: var(--palette-common-white); } diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx new file mode 100644 index 000000000..df927491d --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx @@ -0,0 +1,37 @@ +import { graphql, requestSubscription } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { ModerateCountsCommentEnteredSubscription } from "coral-admin/__generated__/ModerateCountsCommentEnteredSubscription.graphql"; +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; +import { GQLMODERATION_QUEUE } from "coral-framework/schema"; + +import changeQueueCount from "./changeQueueCount"; + +const ModerateCountsCommentEnteredSubscription = createSubscription( + "subscribeToCommentEntered", + ( + environment: Environment, + variables: SubscriptionVariables + ) => + requestSubscription(environment, { + subscription: graphql` + subscription ModerateCountsCommentEnteredSubscription($storyID: ID) { + commentEnteredModerationQueue(storyID: $storyID) { + queue + } + } + `, + variables, + updater: store => { + const root = store.getRootField("commentEnteredModerationQueue")!; + const queue = root.getValue("queue") as GQLMODERATION_QUEUE; + const change = 1; + changeQueueCount(store, change, queue, variables.storyID); + }, + }) +); + +export default ModerateCountsCommentEnteredSubscription; diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx new file mode 100644 index 000000000..ee0346c0e --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx @@ -0,0 +1,40 @@ +import { graphql, requestSubscription } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { ModerateCountsCommentLeftSubscription } from "coral-admin/__generated__/ModerateCountsCommentLeftSubscription.graphql"; +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; +import { GQLMODERATION_QUEUE } from "coral-framework/schema"; + +import changeQueueCount from "./changeQueueCount"; + +const ModerateCountsCommentLeftSubscription = createSubscription( + "subscribeToCommentLeft", + ( + environment: Environment, + variables: SubscriptionVariables + ) => + requestSubscription(environment, { + subscription: graphql` + subscription ModerateCountsCommentLeftSubscription($storyID: ID) { + commentLeftModerationQueue(storyID: $storyID) { + queue + } + commentLeftModerationQueue(storyID: $storyID) { + queue + } + } + `, + variables, + updater: store => { + const root = store.getRootField("commentLeftModerationQueue")!; + const queue = root.getValue("queue") as GQLMODERATION_QUEUE; + const change = -1; + changeQueueCount(store, change, queue, variables.storyID); + }, + }) +); + +export default ModerateCountsCommentLeftSubscription; diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx index 1fa1c37cb..6bd1acad7 100644 --- a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx @@ -1,10 +1,16 @@ -import React from "react"; +import React, { useEffect } from "react"; import { graphql } from "react-relay"; import { ModerateNavigationContainer_moderationQueues as ModerationQueuesData } from "coral-admin/__generated__/ModerateNavigationContainer_moderationQueues.graphql"; import { ModerateNavigationContainer_story as StoryData } from "coral-admin/__generated__/ModerateNavigationContainer_story.graphql"; -import { withFragmentContainer } from "coral-framework/lib/relay"; +import { + combineDisposables, + useSubscription, + withFragmentContainer, +} from "coral-framework/lib/relay"; +import ModerateCountsCommentEnteredSubscription from "./ModerateCountsCommentEnteredSubscription"; +import ModerateCountsCommentLeftSubscription from "./ModerateCountsCommentLeftSubscription"; import Navigation from "./Navigation"; interface Props { @@ -13,6 +19,29 @@ interface Props { } const ModerateNavigationContainer: React.FunctionComponent = props => { + const subscribeToCommentEntered = useSubscription( + ModerateCountsCommentEnteredSubscription + ); + const subscribeToCommentLeft = useSubscription( + ModerateCountsCommentLeftSubscription + ); + + useEffect(() => { + if (!props.moderationQueues) { + return; + } + const vars = { + storyID: props.story && props.story.id, + }; + const disposable = combineDisposables( + subscribeToCommentEntered(vars), + subscribeToCommentLeft(vars) + ); + return () => { + disposable.dispose(); + }; + }, [Boolean(props.moderationQueues), props.story]); + if (!props.moderationQueues) { return ; } diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerationCountsSubscription.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerationCountsSubscription.tsx new file mode 100644 index 000000000..d21915478 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerationCountsSubscription.tsx @@ -0,0 +1,61 @@ +import { graphql, requestSubscription } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { ModerationCountsSubscription } from "coral-admin/__generated__/ModerationCountsSubscription.graphql"; +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; + +const ModerationCountsSubscription = createSubscription( + "subscribeToCounts", + ( + environment: Environment, + variables: SubscriptionVariables + ) => + requestSubscription(environment, { + subscription: graphql` + subscription ModerationCountsSubscription($storyID: ID) { + commentEnteredModerationQueue(storyID: $storyID) { + queue + } + commentLeftModerationQueue(storyID: $storyID) { + queue + } + } + `, + variables, + updater: store => { + let queue: string; + let change: number; + + const enteredRoot = store.getRootField("commentEnteredModerationQueue"); + const leftRoot = store.getRootField("commentLeftModerationQueue"); + if (enteredRoot) { + queue = enteredRoot.getValue("queue") as string; + change = +1; + } else if (leftRoot) { + queue = leftRoot.getValue("queue") as string; + change = -1; + } else { + throw new Error("Expected a subscription result"); + } + + const moderationQueuesProxy = store + .getRoot() + .getLinkedRecord("moderationQueues", { storyID: variables.storyID })!; + if (!moderationQueuesProxy) { + return; + } + const queueProxy = moderationQueuesProxy.getLinkedRecord( + queue!.toLocaleLowerCase() + ); + if (!queueProxy) { + return; + } + queueProxy.setValue(queueProxy.getValue("count") + change, "count"); + }, + }) +); + +export default ModerationCountsSubscription; diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/changeQueueCount.ts b/src/core/client/admin/routes/Moderate/ModerateNavigation/changeQueueCount.ts new file mode 100644 index 000000000..8ad208bd6 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/changeQueueCount.ts @@ -0,0 +1,23 @@ +import { GQLMODERATION_QUEUE } from "coral-framework/schema"; +import { RecordSourceSelectorProxy } from "relay-runtime"; + +export default function changeQueueCount( + store: RecordSourceSelectorProxy, + change: number, + queue: GQLMODERATION_QUEUE, + storyID: string | null = null +) { + const moderationQueuesProxy = store + .getRoot() + .getLinkedRecord("moderationQueues", { storyID })!; + if (!moderationQueuesProxy) { + return; + } + const queueProxy = moderationQueuesProxy.getLinkedRecord( + queue!.toLocaleLowerCase() + ); + if (!queueProxy) { + return; + } + queueProxy.setValue(queueProxy.getValue("count") + change, "count"); +} diff --git a/src/core/client/admin/routes/Moderate/Queue/Queue.css b/src/core/client/admin/routes/Moderate/Queue/Queue.css index d671aef7c..b7fce6c41 100644 --- a/src/core/client/admin/routes/Moderate/Queue/Queue.css +++ b/src/core/client/admin/routes/Moderate/Queue/Queue.css @@ -1,5 +1,6 @@ .root { width: calc(94 * var(--mini-unit)); + position: relative; } .exitTransition { @@ -14,3 +15,13 @@ .exitTransitionDone { opacity: 0; } +.viewNewButtonContainer { + position: absolute; + width: 100%; +} +.viewNewButton { + position: absolute; + z-index: 10; + top: -16px; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25); +} diff --git a/src/core/client/admin/routes/Moderate/Queue/Queue.spec.tsx b/src/core/client/admin/routes/Moderate/Queue/Queue.spec.tsx index ad2982e3c..351e545eb 100644 --- a/src/core/client/admin/routes/Moderate/Queue/Queue.spec.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/Queue.spec.tsx @@ -14,9 +14,11 @@ it("renders correctly with load more", () => { comments: [], settings: {}, onLoadMore: noop, - hasMore: true, + hasLoadMore: true, disableLoadMore: false, danglingLogic: () => true, + viewer: { id: "me", username: "Mirai" }, + onViewNew: noop, }; const renderer = createRenderer(); renderer.render(); @@ -28,9 +30,11 @@ it("renders correctly without load more", () => { comments: [], settings: {}, onLoadMore: noop, - hasMore: false, + hasLoadMore: false, disableLoadMore: false, danglingLogic: () => true, + viewer: { id: "me", username: "Mirai" }, + onViewNew: noop, }; const renderer = createRenderer(); renderer.render(); diff --git a/src/core/client/admin/routes/Moderate/Queue/Queue.tsx b/src/core/client/admin/routes/Moderate/Queue/Queue.tsx index 7962bcaaa..923864255 100644 --- a/src/core/client/admin/routes/Moderate/Queue/Queue.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/Queue.tsx @@ -1,8 +1,9 @@ +import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import AutoLoadMore from "coral-admin/components/AutoLoadMore"; -import { Flex, HorizontalGutter } from "coral-ui/components"; +import { Button, Flex, HorizontalGutter } from "coral-ui/components"; import { PropTypesOf } from "coral-ui/types"; import ModerateCardContainer from "../ModerateCard"; @@ -14,25 +15,45 @@ interface Props { { id: string } & PropTypesOf["comment"] >; settings: PropTypesOf["settings"]; + viewer: PropTypesOf["viewer"]; onLoadMore: () => void; - hasMore: boolean; + onViewNew?: () => void; + hasLoadMore: boolean; disableLoadMore: boolean; danglingLogic: PropTypesOf["danglingLogic"]; emptyElement?: React.ReactElement; allStories?: boolean; + viewNewCount?: number; } const Queue: FunctionComponent = ({ settings, comments, - hasMore, + hasLoadMore: hasMore, disableLoadMore, onLoadMore, danglingLogic, emptyElement, allStories, + viewer, + viewNewCount, + onViewNew, }) => ( + {Boolean(viewNewCount && viewNewCount > 0) && ( + + + + + + )} {comments.map(c => ( = ({ > + ) => + requestSubscription(environment, { + subscription: graphql` + subscription QueueCommentEnteredSubscription( + $storyID: ID + $queue: MODERATION_QUEUE! + ) { + commentEnteredModerationQueue(storyID: $storyID, queue: $queue) { + comment { + id + createdAt + ...ModerateCardContainer_comment + } + } + } + `, + variables, + updater: store => { + handleCommentEnteredModerationQueue( + store, + variables.queue, + variables.storyID || null + ); + }, + }) +); + +export default QueueSubscription; diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx new file mode 100644 index 000000000..b419d5942 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx @@ -0,0 +1,72 @@ +import { graphql, requestSubscription } from "react-relay"; +import { Environment, RecordSourceSelectorProxy } from "relay-runtime"; + +import { QueueCommentLeftSubscription } from "coral-admin/__generated__/QueueCommentLeftSubscription.graphql"; +import { getQueueConnection } from "coral-admin/helpers"; +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; +import { GQLMODERATION_QUEUE_RL } from "coral-framework/schema"; + +function handleCommentLeftModerationQueue( + store: RecordSourceSelectorProxy, + queue: GQLMODERATION_QUEUE_RL, + storyID: string | null +) { + const rootField = store.getRootField("commentLeftModerationQueue"); + if (!rootField) { + return; + } + const commentID = rootField.getLinkedRecord("comment")!.getValue("id")!; + const commentInStore = store.get(commentID); + if (commentInStore) { + // Mark that the status of the comment was live updated. + commentInStore.setValue(true, "statusLiveUpdated"); + } + const connection = getQueueConnection(store, queue, storyID); + if (connection) { + const linked = connection.getLinkedRecords("viewNewEdges") || []; + connection.setLinkedRecords( + linked.filter( + r => r!.getLinkedRecord("node")!.getValue("id") !== commentID + ), + "viewNewEdges" + ); + } +} + +const QueueSubscription = createSubscription( + "subscribeToQueueCommentLeft", + ( + environment: Environment, + variables: SubscriptionVariables + ) => + requestSubscription(environment, { + subscription: graphql` + subscription QueueCommentLeftSubscription( + $storyID: ID + $queue: MODERATION_QUEUE! + ) { + commentLeftModerationQueue(storyID: $storyID, queue: $queue) { + comment { + id + status + ...MarkersContainer_comment @relay(mask: false) + ...ModeratedByContainer_comment @relay(mask: false) + } + } + } + `, + variables, + updater: store => { + handleCommentLeftModerationQueue( + store, + variables.queue, + variables.storyID || null + ); + }, + }) +); + +export default QueueSubscription; diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx index 14f05c6f5..34c444404 100644 --- a/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx @@ -1,21 +1,35 @@ import { Localized } from "fluent-react/compat"; -import { RouteProps } from "found"; -import React from "react"; +import React, { FunctionComponent, useCallback, useEffect } from "react"; import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay"; -import { QueueRoute_queue as QueueData } from "coral-admin/__generated__/QueueRoute_queue.graphql"; -import { QueueRoute_settings as SettingsData } from "coral-admin/__generated__/QueueRoute_settings.graphql"; +import { QueueRoute_queue } from "coral-admin/__generated__/QueueRoute_queue.graphql"; +import { QueueRoute_settings } from "coral-admin/__generated__/QueueRoute_settings.graphql"; +import { QueueRoute_viewer } from "coral-admin/__generated__/QueueRoute_viewer.graphql"; import { QueueRoutePaginationPendingQueryVariables } from "coral-admin/__generated__/QueueRoutePaginationPendingQuery.graphql"; import { IntersectionProvider } from "coral-framework/lib/intersection"; -import { withPaginationContainer } from "coral-framework/lib/relay"; +import { + combineDisposables, + useLoadMore, + useMutation, + useSubscription, + withPaginationContainer, +} from "coral-framework/lib/relay"; +import { GQLMODERATION_QUEUE } from "coral-framework/schema"; +import { withRouteConfig } from "coral-framework/lib/router"; import EmptyMessage from "./EmptyMessage"; import LoadingQueue from "./LoadingQueue"; import Queue from "./Queue"; +import QueueCommentEnteredSubscription from "./QueueCommentEnteredSubscription"; +import QueueCommentLeftSubscription from "./QueueCommentLeftSubscription"; +import QueueViewNewMutation from "./QueueViewNewMutation"; -interface QueueRouteProps { - queue: QueueData; - settings: SettingsData; +interface Props { + isLoading: boolean; + queueName: GQLMODERATION_QUEUE; + queue: QueueRoute_queue | null; + settings: QueueRoute_settings | null; + viewer: QueueRoute_viewer | null; relay: RelayPaginationProp; emptyElement: React.ReactElement; storyID?: string; @@ -25,136 +39,174 @@ interface QueueRouteProps { const danglingLogic = (status: string) => ["APPROVED", "REJECTED"].indexOf(status) >= 0; -export class QueueRoute extends React.Component { - public static routeConfig: RouteProps; - - public state = { - disableLoadMore: false, - }; - - public render() { - const comments = this.props.queue.comments.edges.map(edge => edge.node); - return ( - - - +export const QueueRoute: FunctionComponent = props => { + const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); + const subscribeToQueueCommentEntered = useSubscription( + QueueCommentEnteredSubscription + ); + const subscribeToQueueCommentLeft = useSubscription( + QueueCommentLeftSubscription + ); + const viewNew = useMutation(QueueViewNewMutation); + const onViewNew = useCallback(() => { + viewNew({ queue: props.queueName, storyID: props.storyID || null }); + }, [props.queueName, props.storyID, viewNew]); + useEffect(() => { + const vars = { + queue: props.queueName, + storyID: props.storyID || null, + }; + const disposable = combineDisposables( + subscribeToQueueCommentEntered(vars), + subscribeToQueueCommentLeft(vars) ); + return () => { + disposable.dispose(); + }; + }, [ + props.storyID, + props.queueName, + subscribeToQueueCommentEntered, + subscribeToQueueCommentLeft, + ]); + if (props.isLoading) { + return ; } - - private loadMore = () => { - if (!this.props.relay.hasMore() || this.props.relay.isLoading()) { - return; - } - this.setState({ disableLoadMore: true }); - this.props.relay.loadMore( - 10, // Fetch the next 10 feed items - error => { - this.setState({ disableLoadMore: false }); - if (error) { - // tslint:disable-next-line:no-console - console.error(error); - } - } - ); - }; -} + const comments = props.queue!.comments.edges.map(edge => edge.node); + const viewNewCount = + (props.queue!.comments.viewNewEdges && + props.queue!.comments.viewNewEdges.length) || + 0; + return ( + + + + ); +}; // TODO: (cvle) If this could be autogenerated.. type FragmentVariables = QueueRoutePaginationPendingQueryVariables; const createQueueRoute = ( + queueName: GQLMODERATION_QUEUE, queueQuery: GraphQLTaggedNode, paginationQuery: GraphQLTaggedNode, emptyElement: React.ReactElement ) => { - const enhanced = (withPaginationContainer< - QueueRouteProps, - QueueRoutePaginationPendingQueryVariables, - FragmentVariables - >( - { - queue: graphql` - fragment QueueRoute_queue on ModerationQueue - @argumentDefinitions( - count: { type: "Int!", defaultValue: 5 } - cursor: { type: "Cursor" } - ) { - count - comments(first: $count, after: $cursor) - @connection(key: "Queue_comments") { - edges { - node { - id - ...ModerateCardContainer_comment - } - } - } - } - `, - settings: graphql` - fragment QueueRoute_settings on Settings { - ...ModerateCardContainer_settings - } - `, - }, - { - direction: "forward", - getConnectionFromProps(props) { - return props.queue && props.queue.comments; - }, - // This is also the default implementation of `getFragmentVariables` if it isn't provided. - getFragmentVariables(prevVars, totalCount) { - return { - ...prevVars, - count: totalCount, - }; - }, - getVariables(props, { count, cursor }, fragmentVariables) { - return { - ...fragmentVariables, - count, - cursor, - }; - }, - query: paginationQuery, - } - )(QueueRoute) as any) as typeof QueueRoute; - - enhanced.routeConfig = { - Component: enhanced, + const enhanced = withRouteConfig({ query: queueQuery, cacheConfig: { force: true }, - render: ({ Component, props, match }) => { - const anyProps = props as any; - if (Component && props) { - const queue = - anyProps.moderationQueues[Object.keys(anyProps.moderationQueues)[0]]; + render: ({ Component, data, match }) => { + if (!Component) { + throw new Error("Missing component"); + } + if (!data || !data.moderationQueues) { return ( ); } - return ; + const queue = + data.moderationQueues[Object.keys(data.moderationQueues)[0]]; + return ( + + ); }, - }; + })( + withPaginationContainer< + Props, + QueueRoutePaginationPendingQueryVariables, + FragmentVariables + >( + { + queue: graphql` + fragment QueueRoute_queue on ModerationQueue + @argumentDefinitions( + count: { type: "Int!", defaultValue: 5 } + cursor: { type: "Cursor" } + ) { + count + comments(first: $count, after: $cursor) + @connection(key: "Queue_comments") { + viewNewEdges { + cursor + } + edges { + node { + id + ...ModerateCardContainer_comment + } + } + } + } + `, + settings: graphql` + fragment QueueRoute_settings on Settings { + ...ModerateCardContainer_settings + } + `, + viewer: graphql` + fragment QueueRoute_viewer on User { + ...ModerateCardContainer_viewer + } + `, + }, + { + direction: "forward", + getConnectionFromProps(props) { + return props.queue && props.queue.comments; + }, + // This is also the default implementation of `getFragmentVariables` if it isn't provided. + getFragmentVariables(prevVars, totalCount) { + return { + ...prevVars, + count: totalCount, + }; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + ...fragmentVariables, + count, + cursor, + }; + }, + query: paginationQuery, + } + )(QueueRoute) + ); return enhanced; }; export const PendingQueueRoute = createQueueRoute( + GQLMODERATION_QUEUE.PENDING, graphql` query QueueRoutePendingQuery($storyID: ID) { moderationQueues(storyID: $storyID) { @@ -165,6 +217,9 @@ export const PendingQueueRoute = createQueueRoute( settings { ...QueueRoute_settings } + viewer { + ...QueueRoute_viewer + } } `, graphql` @@ -191,6 +246,7 @@ export const PendingQueueRoute = createQueueRoute( ); export const ReportedQueueRoute = createQueueRoute( + GQLMODERATION_QUEUE.REPORTED, graphql` query QueueRouteReportedQuery($storyID: ID) { moderationQueues(storyID: $storyID) { @@ -201,6 +257,9 @@ export const ReportedQueueRoute = createQueueRoute( settings { ...QueueRoute_settings } + viewer { + ...QueueRoute_viewer + } } `, graphql` @@ -227,6 +286,7 @@ export const ReportedQueueRoute = createQueueRoute( ); export const UnmoderatedQueueRoute = createQueueRoute( + GQLMODERATION_QUEUE.UNMODERATED, graphql` query QueueRouteUnmoderatedQuery($storyID: ID) { moderationQueues(storyID: $storyID) { @@ -237,6 +297,9 @@ export const UnmoderatedQueueRoute = createQueueRoute( settings { ...QueueRoute_settings } + viewer { + ...QueueRoute_viewer + } } `, graphql` diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx new file mode 100644 index 000000000..625dfd213 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx @@ -0,0 +1,35 @@ +import { ConnectionHandler, Environment } from "relay-runtime"; + +import { getQueueConnection } from "coral-admin/helpers"; +import { + commitLocalUpdatePromisified, + createMutation, +} from "coral-framework/lib/relay"; +import { GQLMODERATION_QUEUE } from "coral-framework/schema"; + +interface QueueViewNewInput { + storyID: string | null; + queue: GQLMODERATION_QUEUE; +} + +const QueueViewNewMutation = createMutation( + "viewNew", + async (environment: Environment, input: QueueViewNewInput) => { + await commitLocalUpdatePromisified(environment, async store => { + const connection = getQueueConnection(store, input.queue, input.storyID); + if (!connection) { + return; + } + const viewNewEdges = connection.getLinkedRecords("viewNewEdges"); + if (!viewNewEdges || viewNewEdges.length === 0) { + return; + } + viewNewEdges.forEach(edge => { + ConnectionHandler.insertEdgeBefore(connection, edge); + }); + connection.setLinkedRecords([], "viewNewEdges"); + }); + } +); + +export default QueueViewNewMutation; diff --git a/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx b/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx index baf621be1..7a036ec75 100644 --- a/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx @@ -3,7 +3,7 @@ import { RouteProps } from "found"; import React from "react"; import { graphql, RelayPaginationProp } from "react-relay"; -import { RejectedQueueRoute_query as QueryData } from "coral-admin/__generated__/RejectedQueueRoute_query.graphql"; +import { RejectedQueueRoute_query } from "coral-admin/__generated__/RejectedQueueRoute_query.graphql"; import { RejectedQueueRoutePaginationQueryVariables } from "coral-admin/__generated__/RejectedQueueRoutePaginationQuery.graphql"; import { IntersectionProvider } from "coral-framework/lib/intersection"; import { withPaginationContainer } from "coral-framework/lib/relay"; @@ -13,7 +13,7 @@ import LoadingQueue from "./LoadingQueue"; import Queue from "./Queue"; interface RejectedQueueRouteProps { - query: QueryData; + query: RejectedQueueRoute_query; relay: RelayPaginationProp; storyID?: string; } @@ -35,10 +35,11 @@ export class RejectedQueueRoute extends React.Component< return ( false; -export default class SingleModerateRoute extends React.Component { - public static routeConfig: RouteProps; - - public render() { - if (!this.props.comment) { - return ; +const SingleModerateRoute: FunctionComponent = props => { + const subscribeToSingleModerate = useSubscription(SingleModerateSubscription); + useEffect(() => { + if (!props.comment) { + return; } - return ( - - - - ); - } -} + const disposable = subscribeToSingleModerate({ + commentID: props.comment.id, + }); + return () => { + disposable.dispose(); + }; + }, [props.comment, subscribeToSingleModerate]); -SingleModerateRoute.routeConfig = { - Component: SingleModerateRoute, + if (!props.comment) { + return ; + } + return ( + + + + ); +}; + +const enhanced = withRouteConfig({ query: graphql` query SingleModerateRouteQuery($commentID: ID!) { comment(id: $commentID) { @@ -46,13 +57,18 @@ SingleModerateRoute.routeConfig = { settings { ...ModerateCardContainer_settings } + viewer { + ...ModerateCardContainer_viewer + } } `, cacheConfig: { force: true }, - render: ({ Component, props }) => { - if (Component && props) { - return ; + render: ({ Component, data }) => { + if (Component && data) { + return ; } return ; }, -}; +})(SingleModerateRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateSubscription.tsx b/src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateSubscription.tsx new file mode 100644 index 000000000..b1002092f --- /dev/null +++ b/src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateSubscription.tsx @@ -0,0 +1,44 @@ +import { graphql, requestSubscription } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { SingleModerateSubscription } from "coral-admin/__generated__/SingleModerateSubscription.graphql"; +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; + +const SingleModerateSubscription = createSubscription( + "subscribeToSingleModerate", + ( + environment: Environment, + variables: SubscriptionVariables + ) => + requestSubscription(environment, { + subscription: graphql` + subscription SingleModerateSubscription($commentID: ID!) { + commentStatusUpdated(id: $commentID) { + comment { + id + status + ...MarkersContainer_comment @relay(mask: false) + ...ModeratedByContainer_comment @relay(mask: false) + } + } + } + `, + variables, + updater: store => { + const commentID = store + .getRootField("commentStatusUpdated")! + .getLinkedRecord("comment")! + .getValue("id")!; + const commentInStore = store.get(commentID); + if (commentInStore) { + // Mark that the status of the comment was live updated. + commentInStore.setValue(true, "statusLiveUpdated"); + } + }, + }) +); + +export default SingleModerateSubscription; diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index c3c3a0b52..f438b764b 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -418,8 +418,12 @@ export const baseComment = createFixture({ author: users.commenters[0], body: "Comment Body", createdAt: "2018-07-06T18:24:00.000Z", - status: GQLCOMMENT_STATUS.NONE, tags: [], + status: GQLCOMMENT_STATUS.NONE, + statusHistory: { + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }, actionCounts: { flag: { reasons: { @@ -438,6 +442,8 @@ export const baseComment = createFixture({ nodes: [], }, story: stories[0], + // TODO: Should be allowed to pass null here.. + parent: undefined, }); export const unmoderatedComments = createFixtures( @@ -577,6 +583,32 @@ export const reportedComments = createFixtures( ], }, }, + { + id: "comment-3", + revision: { + id: "comment-3-revision-3", + }, + permalink: "http://localhost/comment/3", + status: GQLCOMMENT_STATUS.PREMOD, + author: users.commenters[3], + body: "World peace at last", + actionCounts: { + flag: { + reasons: { + COMMENT_REPORTED_SPAM: 1, + }, + }, + }, + flags: { + nodes: [ + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_SPAM, + flagger: users.commenters[2], + additionalDetails: "", + }, + ], + }, + }, ], baseComment ); diff --git a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap deleted file mode 100644 index 4d2c1490a..000000000 --- a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap +++ /dev/null @@ -1,3299 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`rejected queue approves comment in rejected queue: count should be 1 1`] = ` - - - 1 - - -`; - -exports[`rejected queue approves comment in rejected queue: dangling 1`] = ` -
-
-
-
-
- - Isabelle - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Spam - - - - 2 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-`; - -exports[`rejected queue renders rejected queue with comments 1`] = ` -
-
-
-
-
-
-
-
- - Isabelle - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Spam - - - - 2 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-
-
-
-
-
- - Ngoc - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Offensive - - - - 3 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-
-
-
-`; - -exports[`rejected queue renders rejected queue with comments and load more 1`] = ` -
-
-
-
-
- - Max - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Offensive - - - - 1 - - - - - Spam - - - - 1 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-`; - -exports[`reported queue approves comment in reported queue: count should be 1 1`] = ` - - - 1 - - -`; - -exports[`reported queue approves comment in reported queue: dangling 1`] = ` -
-
-
-
-
- - Isabelle - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Spam - - - - 2 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-`; - -exports[`reported queue rejects comment in reported queue: count should be 1 1`] = ` - - - 1 - - -`; - -exports[`reported queue rejects comment in reported queue: dangling 1`] = ` -
-
-
-
-
- - Isabelle - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Spam - - - - 2 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-`; - -exports[`reported queue renders reported queue with comments 1`] = ` -
-
-
-
-
-
-
-
- - Isabelle - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Spam - - - - 2 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-
-
-
-
-
- - Ngoc - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Offensive - - - - 3 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-
-
-
-`; - -exports[`reported queue renders reported queue with comments 2`] = ` -
-
-
-
-
-
-
-
- - Isabelle - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Spam - - - - 2 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-
-
-
-
-
- - Ngoc - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - - Offensive - - - - 3 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-
-
-
-`; - -exports[`reported queue renders reported queue with comments and load more 1`] = ` -
-
-
-
-
- - Max - - - -
-
-
-
-
- -
- - Story - - : - - Finally a Cure for Cancer - - - - Moderate Story - -
-
-
-
- - Pre-Mod - - - - Offensive - - - - 1 - - - - - Spam - - - - 1 - - -
- -
-
-
-
-
-
-
-
- Decision -
-
- - -
-
-
-
-`; - -exports[`search bar all stories active search with no results 1`] = ` -
-
-
-
-
-
-
-
- -
- Stories: -
-
- -
- -
-
-
-
-
- -
-
-
-
-
-
-`; - -exports[`search bar all stories active search with too many results 1`] = ` -
  • - - - See all results - - - -
  • -`; - -exports[`search bar all stories renders search bar 1`] = ` -
    -
    -
    -
    -
    -
    -
    -
    - -
    - -
    -
    -
    -
    -
    - -
    -
    -`; - -exports[`single comment view approves single comment 1`] = ` -
    -
    -
    -
    -
    - - Isabelle - - - -
    -
    -
    -
    -
    - -
    -
    -
    - - - Spam - - - - 2 - - -
    - -
    -
    -
    -
    -
    -
    -
    -
    - Decision -
    -
    - - -
    -
    -
    -
    -`; - -exports[`single comment view rejects single comment 1`] = ` -
    -
    -
    -
    -
    - - Isabelle - - - -
    -
    -
    -
    -
    - -
    -
    -
    - - - Spam - - - - 2 - - -
    - -
    -
    -
    -
    -
    -
    -
    -
    - Decision -
    -
    - - -
    -
    -
    -
    -`; - -exports[`single comment view renders single comment view 1`] = ` -
    -
    -
    - - Go to moderation queues - -
    - Single Comment View -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - Isabelle - - - -
    -
    -
    -
    -
    - -
    -
    -
    - - - Spam - - - - 2 - - -
    - -
    -
    -
    -
    -
    -
    -
    -
    - Decision -
    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    -`; - -exports[`tab bar renders tab bar (empty queues) 1`] = ` - -`; diff --git a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap new file mode 100644 index 000000000..e0817c02e --- /dev/null +++ b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap @@ -0,0 +1,1486 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`approves comment in reported queue: count should be 1 1`] = ` + + + 1 + + +`; + +exports[`approves comment in reported queue: dangling 1`] = ` +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +`; + +exports[`rejects comment in reported queue: count should be 1 1`] = ` + + + 1 + + +`; + +exports[`rejects comment in reported queue: dangling 1`] = ` +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +`; + +exports[`renders reported queue with comments 1`] = ` +
    +
    +
    +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Ngoc + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Offensive + + + + 3 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`renders reported queue with comments 2`] = ` +
    +
    +
    +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Ngoc + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Offensive + + + + 3 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`renders reported queue with comments and load more 1`] = ` +
    +
    +
    +
    +
    + + Max + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + Pre-Mod + + + + Offensive + + + + 1 + + + + + Spam + + + + 1 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +`; diff --git a/src/core/client/admin/test/moderate/__snapshots__/rejectedQueue.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/rejectedQueue.spec.tsx.snap new file mode 100644 index 000000000..01c1a0bac --- /dev/null +++ b/src/core/client/admin/test/moderate/__snapshots__/rejectedQueue.spec.tsx.snap @@ -0,0 +1,849 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`approves comment in rejected queue: count should be 1 1`] = ` + + + 1 + + +`; + +exports[`approves comment in rejected queue: dangling 1`] = ` +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +`; + +exports[`renders rejected queue with comments 1`] = ` +
    +
    +
    +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Ngoc + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Offensive + + + + 3 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`renders rejected queue with comments and load more 1`] = ` +
    +
    +
    +
    +
    + + Max + + + +
    +
    +
    +
    +
    + +
    + + Story + + : + + Finally a Cure for Cancer + + + + Moderate Story + +
    +
    +
    +
    + + + Offensive + + + + 1 + + + + + Spam + + + + 1 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +`; diff --git a/src/core/client/admin/test/moderate/__snapshots__/searchBar.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/searchBar.spec.tsx.snap new file mode 100644 index 000000000..9003dc74b --- /dev/null +++ b/src/core/client/admin/test/moderate/__snapshots__/searchBar.spec.tsx.snap @@ -0,0 +1,255 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`all stories active search with no results 1`] = ` +
    +
    +
    +
    +
    +
    +
    +
    + +
    + Stories: +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +`; + +exports[`all stories active search with too many results 1`] = ` +
  • + + + See all results + + + +
  • +`; + +exports[`all stories renders search bar 1`] = ` +
    +
    +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +`; diff --git a/src/core/client/admin/test/moderate/__snapshots__/singleComment.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/singleComment.spec.tsx.snap new file mode 100644 index 000000000..5eafb4c6e --- /dev/null +++ b/src/core/client/admin/test/moderate/__snapshots__/singleComment.spec.tsx.snap @@ -0,0 +1,586 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`approves single comment 1`] = ` +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +`; + +exports[`rejects single comment 1`] = ` +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +`; + +exports[`renders single comment view 1`] = ` +
    +
    +
    + + Go to moderation queues + +
    + Single Comment View +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Isabelle + + + +
    +
    +
    +
    +
    + +
    +
    +
    + + + Spam + + + + 2 + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Decision +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/src/core/client/admin/test/moderate/__snapshots__/tabBar.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/tabBar.spec.tsx.snap new file mode 100644 index 000000000..763727b6a --- /dev/null +++ b/src/core/client/admin/test/moderate/__snapshots__/tabBar.spec.tsx.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`tab bar renders tab bar (empty queues) 1`] = ` + +`; diff --git a/src/core/client/admin/test/moderate/liveCounts.spec.tsx b/src/core/client/admin/test/moderate/liveCounts.spec.tsx new file mode 100644 index 000000000..eb4f67fd4 --- /dev/null +++ b/src/core/client/admin/test/moderate/liveCounts.spec.tsx @@ -0,0 +1,123 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLComment, + GQLMODERATION_QUEUE, + GQLResolver, + SubscriptionToCommentEnteredModerationQueueResolver, +} from "coral-framework/schema"; +import { + act, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + wait, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + reportedComments, + settings, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { testRenderer, context, subscriptionHandler }; +} + +it("live update count", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer(); + const verifyCount = (queue: GQLMODERATION_QUEUE, no: number) => { + within( + within(testRenderer.root).getByTestID( + `moderate-navigation-${queue.toLocaleLowerCase()}-count` + ) + ).getByText(no.toString()); + }; + const commentEntered = (queue: GQLMODERATION_QUEUE, comment: GQLComment) => { + subscriptionHandler.dispatch< + SubscriptionToCommentEnteredModerationQueueResolver + >("commentEnteredModerationQueue", variables => { + if (variables.queue && variables.queue !== queue) { + return; + } + return { + queue, + comment, + }; + }); + }; + const commentLeft = (queue: GQLMODERATION_QUEUE, comment: GQLComment) => { + subscriptionHandler.dispatch< + SubscriptionToCommentEnteredModerationQueueResolver + >("commentLeftModerationQueue", variables => { + if (variables.queue && variables.queue !== queue) { + return; + } + return { + queue, + comment, + }; + }); + }; + await act(async () => { + await wait(() => + expect(subscriptionHandler.has("commentEnteredModerationQueue")).toBe( + true + ) + ); + verifyCount(GQLMODERATION_QUEUE.REPORTED, 0); + verifyCount(GQLMODERATION_QUEUE.PENDING, 0); + verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0); + + commentEntered(GQLMODERATION_QUEUE.REPORTED, reportedComments[0]); + + verifyCount(GQLMODERATION_QUEUE.REPORTED, 1); + verifyCount(GQLMODERATION_QUEUE.PENDING, 0); + verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0); + + commentEntered(GQLMODERATION_QUEUE.REPORTED, reportedComments[1]); + + verifyCount(GQLMODERATION_QUEUE.REPORTED, 2); + verifyCount(GQLMODERATION_QUEUE.PENDING, 0); + verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0); + + commentEntered(GQLMODERATION_QUEUE.PENDING, reportedComments[2]); + commentEntered(GQLMODERATION_QUEUE.PENDING, reportedComments[2]); + commentLeft(GQLMODERATION_QUEUE.REPORTED, reportedComments[1]); + + verifyCount(GQLMODERATION_QUEUE.REPORTED, 1); + verifyCount(GQLMODERATION_QUEUE.PENDING, 2); + verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0); + }); +}); diff --git a/src/core/client/admin/test/moderate/moderate.spec.tsx b/src/core/client/admin/test/moderate/moderate.spec.tsx deleted file mode 100644 index 341bffcf7..000000000 --- a/src/core/client/admin/test/moderate/moderate.spec.tsx +++ /dev/null @@ -1,1158 +0,0 @@ -import { noop } from "lodash"; -import { ReactTestInstance, ReactTestRenderer } from "react-test-renderer"; - -import { pureMerge } from "coral-common/utils"; -import { - GQLCOMMENT_STATUS, - GQLResolver, - ModerationQueueToCommentsResolver, - MutationToApproveCommentResolver, - MutationToRejectCommentResolver, - QueryToCommentResolver, -} from "coral-framework/schema"; -import { - act, - createMutationResolverStub, - createQueryResolverStub, - createResolversStub, - CreateTestRendererParams, - findParentWithType, - replaceHistoryLocation, - toJSON, - wait, - waitForElement, - waitUntilThrow, - within, -} from "coral-framework/testHelpers"; - -import create from "../create"; -import { - emptyModerationQueues, - emptyRejectedComments, - emptyStories, - rejectedComments, - reportedComments, - settings, - stories, - storyConnection, - users, -} from "../fixtures"; - -const viewer = users.admins[0]; - -beforeEach(async () => { - replaceHistoryLocation("http://localhost/admin/moderate"); -}); - -async function createTestRenderer( - params: CreateTestRendererParams = {} -) { - const { testRenderer, context } = create({ - ...params, - resolvers: pureMerge( - createResolversStub({ - Query: { - settings: () => settings, - viewer: () => viewer, - moderationQueues: () => emptyModerationQueues, - comments: () => emptyRejectedComments, - }, - }), - params.resolvers - ), - initLocalState: (localRecord, source, environment) => { - localRecord.setValue(true, "loggedIn"); - if (params.initLocalState) { - params.initLocalState(localRecord, source, environment); - } - }, - }); - return { testRenderer, context }; -} - -describe("search bar", () => { - const openSearchBar = async (testRenderer: ReactTestRenderer) => { - await act(async () => { - await waitForElement(() => - within(testRenderer.root).getByTestID("moderate-searchBar-container") - ); - }); - const searchBar = within(testRenderer.root).getByTestID( - "moderate-searchBar-container" - ); - const textField = within(searchBar).getByLabelText( - "Search or jump to story..." - ); - const form = findParentWithType(textField, "form")!; - act(() => textField.props.onFocus({})); - return { searchBar, textField, form }; - }; - - describe("all stories", () => { - it("renders search bar", async () => { - let searchBar: ReactTestInstance; - await act(async () => { - const { testRenderer } = await createTestRenderer(); - searchBar = await waitForElement(() => - within(testRenderer.root).getByTestID("moderate-searchBar-container") - ); - }); - expect(within(searchBar!).toJSON()).toMatchSnapshot(); - }); - - describe("active", () => { - it("search with no results", async () => { - const query = "InterestingStory"; - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - stories: ({ variables }) => { - expectAndFail(variables.query).toBe(query); - return emptyStories; - }, - }, - }), - }); - const { searchBar, textField, form } = await openSearchBar( - testRenderer - ); - expect(within(searchBar).toJSON()).toMatchSnapshot(); - - await act(async () => { - // Search for sth. - textField.props.onChange(query); - form.props.onSubmit(); - - // Ensure no results message is shown. - await wait(() => - within(searchBar).getByText("No results", { exact: false }) - ); - }); - - act(() => { - // Blurring should close the listbox. - textField.props.onBlur({}); - }); - expect(within(searchBar).queryByText("No results")).toBeNull(); - }); - it("search with actual results", async () => { - const query = "InterestingStory"; - const { - testRenderer, - context: { transitionControl }, - } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - stories: ({ variables }) => { - expectAndFail(variables.query).toBe(query); - return storyConnection; - }, - }, - }), - }); - transitionControl.allowTransition = false; - const { searchBar, textField, form } = await openSearchBar( - testRenderer - ); - - const story = storyConnection.edges[0].node; - - let storyOption: ReactTestInstance; - - await act(async () => { - // Search for sth. - textField.props.onChange(query); - form.props.onSubmit(); - - // Find the story in the search results. - storyOption = findParentWithType( - await waitForElement(() => - within(searchBar).getByText(story.metadata!.title!, { - exact: false, - }) - ), - "li" - )!; - }); - - // Go to story. - storyOption!.props.onClick({ button: 0, preventDefault: noop }); - - // Expect a routing request was made to the right url. - expect(transitionControl.history[0].pathname).toBe( - `/admin/moderate/${story.id}` - ); - }); - it("search with too many results", async () => { - const query = "InterestingStory"; - const { - testRenderer, - context: { transitionControl }, - } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - stories: ({ variables }) => { - expectAndFail(variables.query).toBe(query); - return pureMerge(storyConnection, { - pageInfo: { hasNextPage: true }, - }); - }, - }, - }), - }); - transitionControl.allowTransition = false; - const { searchBar, textField, form } = await openSearchBar( - testRenderer - ); - - let seeAllOption: ReactTestInstance; - await act(async () => { - // Search for sth. - textField.props.onChange(query); - form.props.onSubmit(); - - // Find see all options in the search results. - seeAllOption = findParentWithType( - await waitForElement(() => - within(searchBar).getByText("See all results", { exact: false }) - ), - "li" - )!; - }); - - expect(within(seeAllOption!).toJSON()).toMatchSnapshot(); - - // Go to story. - seeAllOption!.props.onClick({ button: 0, preventDefault: noop }); - - // Expect a routing request was made to the right url. - expect(transitionControl.history[0].pathname).toBe("/admin/stories"); - expect(transitionControl.history[0].search).toBe(`?q=${query}`); - }); - }); - }); - describe("specified story", () => { - beforeEach(() => { - replaceHistoryLocation( - `http://localhost/admin/moderate/${stories[0].id}` - ); - }); - it("renders search bar", async () => { - await act(async () => { - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - story: () => stories[0], - }, - }), - }); - const searchBar = await waitForElement(() => - within(testRenderer.root).getByTestID("moderate-searchBar-container") - ); - const textField = within(searchBar).getByLabelText( - "Search or jump to story..." - ); - expect(textField.props.placeholder).toBe(stories[0].metadata!.title); - }); - }); - it("shows moderate all option", async () => { - const { - testRenderer, - context: { transitionControl }, - } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - story: () => stories[0], - }, - }), - }); - transitionControl.allowTransition = false; - const { searchBar } = await openSearchBar(testRenderer); - - // Find see all options in the search results. - const moderateAllOptions = findParentWithType( - await waitForElement(() => - within(searchBar).getByText("Moderate all", { exact: false }) - ), - "li" - )!; - - // Activate moderate all. - moderateAllOptions.props.onClick({ button: 0, preventDefault: noop }); - - // Expect a routing request was made to the right url. - expect(transitionControl.history[0].pathname).toBe("/admin/moderate"); - }); - }); -}); - -describe("tab bar", () => { - it("renders tab bar (empty queues)", async () => { - await act(async () => { - const { testRenderer } = await createTestRenderer(); - const { getByTestID } = within(testRenderer.root); - await waitForElement(() => getByTestID("moderate-container")); - expect( - toJSON(getByTestID("moderate-tabBar-container")) - ).toMatchSnapshot(); - }); - }); - it("should not show moderate story link in comment cards", async () => { - await act(async () => { - const { testRenderer } = await createTestRenderer(); - const { getByTestID } = within(testRenderer.root); - await waitForElement(() => getByTestID("moderate-container")); - expect( - within(testRenderer.root).queryByText("Moderate Story") - ).toBeNull(); - }); - }); -}); - -describe("moderating specific story", () => { - it("passes storyID to the endpoints", async () => { - replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`); - await act(async () => { - await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: ({ variables }) => { - expectAndFail(variables.storyID).toBe(stories[0].id); - return emptyModerationQueues; - }, - comments: ({ variables }) => { - expectAndFail(variables.storyID).toBe(stories[0].id); - return emptyRejectedComments; - }, - }, - }), - }); - }); - }); -}); - -describe("reported queue", () => { - it("renders empty reported queue", async () => { - await act(async () => { - const { testRenderer } = await createTestRenderer(); - const { getByText } = within(testRenderer.root); - await waitForElement(() => - getByText("no more reported", { exact: false }) - ); - }); - }); - - it("renders empty pending queue", async () => { - replaceHistoryLocation("http://localhost/admin/moderate/pending"); - const { testRenderer } = await createTestRenderer(); - const { getByText } = within(testRenderer.root); - await waitForElement(() => getByText("no more pending", { exact: false })); - }); - - it("renders empty unmoderated queue", async () => { - replaceHistoryLocation("http://localhost/admin/moderate/unmoderated"); - const { testRenderer } = await createTestRenderer(); - const { getByText } = within(testRenderer.root); - await waitForElement(() => - getByText("comments have been moderated", { exact: false }) - ); - }); - - it("renders empty rejected queue", async () => { - replaceHistoryLocation("http://localhost/admin/moderate/rejected"); - const { testRenderer } = await createTestRenderer(); - const { getByText } = within(testRenderer.root); - await waitForElement(() => - getByText("no rejected comments", { exact: false }) - ); - }); - - it("renders reported queue with comments", async () => { - await act(async () => { - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: () => - pureMerge(emptyModerationQueues, { - reported: { - count: 2, - comments: createQueryResolverStub< - ModerationQueueToCommentsResolver - >(({ variables }) => { - expectAndFail(variables).toEqual({ first: 5 }); - return { - edges: [ - { - node: reportedComments[0], - cursor: reportedComments[0].createdAt, - }, - { - node: reportedComments[1], - cursor: reportedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[1].createdAt, - hasNextPage: false, - }, - }; - }) as any, - }, - }), - }, - }), - }); - const { getByTestID } = within(testRenderer.root); - await waitForElement(() => getByTestID("moderate-container")); - expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot(); - }); - }); - - it("renders reported queue with comments", async () => { - await act(async () => { - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: () => - pureMerge(emptyModerationQueues, { - reported: { - count: 2, - comments: createQueryResolverStub< - ModerationQueueToCommentsResolver - >(({ variables }) => { - expectAndFail(variables).toEqual({ first: 5 }); - return { - edges: [ - { - node: reportedComments[0], - cursor: reportedComments[0].createdAt, - }, - { - node: reportedComments[1], - cursor: reportedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[1].createdAt, - hasNextPage: false, - }, - }; - }), - }, - }), - }, - }), - }); - const { getByTestID } = within(testRenderer.root); - await waitForElement(() => getByTestID("moderate-container")); - expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot(); - }); - }); - it("show details of comment with flags", async () => { - await act(async () => { - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: () => - pureMerge(emptyModerationQueues, { - reported: { - count: 1, - comments: createQueryResolverStub< - ModerationQueueToCommentsResolver - >(({ variables }) => { - expectAndFail(variables).toEqual({ first: 5 }); - return { - edges: [ - { - node: reportedComments[0], - cursor: reportedComments[0].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[0].createdAt, - hasNextPage: false, - }, - }; - }), - }, - }), - }, - }), - }); - const { getByTestID } = within(testRenderer.root); - const reported = await waitForElement(() => - getByTestID(`moderate-comment-${reportedComments[0].id}`) - ); - expect( - within(reported).queryByText( - reportedComments[0].flags.nodes[0].additionalDetails! - ) - ).toBeNull(); - within(reported) - .getByText("Details", { selector: "button" }) - .props.onClick(); - within(reported).getByText( - reportedComments[0].flags.nodes[0].additionalDetails! - ); - }); - }); - it("shows a moderate story", async () => { - await act(async () => { - const { - testRenderer, - context: { transitionControl }, - } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: () => - pureMerge(emptyModerationQueues, { - reported: { - count: 2, - comments: createQueryResolverStub< - ModerationQueueToCommentsResolver - >(({ variables }) => { - expectAndFail(variables).toEqual({ first: 5 }); - return { - edges: [ - { - node: reportedComments[0], - cursor: reportedComments[0].createdAt, - }, - { - node: reportedComments[1], - cursor: reportedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[1].createdAt, - hasNextPage: false, - }, - }; - }) as any, - }, - }), - }, - }), - }); - const moderateStory = await waitForElement( - () => within(testRenderer.root).getAllByText("Moderate Story")[0] - ); - transitionControl.allowTransition = false; - moderateStory.props.onClick({}); - // Expect a routing request was made to the right url. - expect(transitionControl.history[0].pathname).toBe( - `/admin/moderate/${reportedComments[0].story.id}` - ); - }); - }); - it("renders reported queue with comments and load more", async () => { - await act(async () => { - const moderationQueuesStub = pureMerge(emptyModerationQueues, { - reported: { - count: 2, - comments: createQueryResolverStub( - ({ variables, callCount }) => { - switch (callCount) { - case 0: - expectAndFail(variables).toEqual({ first: 5 }); - return { - edges: [ - { - node: reportedComments[0], - cursor: reportedComments[0].createdAt, - }, - { - node: reportedComments[1], - cursor: reportedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[1].createdAt, - hasNextPage: true, - }, - }; - default: - expectAndFail(variables).toEqual({ - first: 10, - after: reportedComments[1].createdAt, - }); - return { - edges: [ - { - node: reportedComments[2], - cursor: reportedComments[2].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[2].createdAt, - hasNextPage: false, - }, - }; - } - } - ) as any, - }, - }); - - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: () => moderationQueuesStub, - }, - }), - }); - const moderateContainer = await waitForElement(() => - within(testRenderer.root).getByTestID("moderate-container") - ); - - const { getByText, getAllByTestID, getByTestID } = within( - moderateContainer - ); - - // Get previous count of comments. - const previousCount = getAllByTestID(/^moderate-comment-.*$/).length; - - const loadMore = await waitForElement(() => getByText("Load More")); - loadMore.props.onClick(); - - // Wait for load more to disappear. - await waitUntilThrow(() => getByText("Load More")); - - // Verify we have one more item now. - const comments = getAllByTestID(/^moderate-comment-.*$/); - expect(comments.length).toBe(previousCount + 1); - - // Verify last one added was our new one - expect(comments[comments.length - 1].props["data-testid"]).toBe( - `moderate-comment-${reportedComments[2].id}` - ); - - // Snapshot of added comment. - expect( - toJSON(getByTestID(`moderate-comment-${reportedComments[2].id}`)) - ).toMatchSnapshot(); - }); - }); - - it("approves comment in reported queue", async () => { - await act(async () => { - const approveCommentStub = createMutationResolverStub< - MutationToApproveCommentResolver - >(({ variables }) => { - expectAndFail(variables).toMatchObject({ - commentID: reportedComments[0].id, - commentRevisionID: reportedComments[0].revision.id, - }); - return { - comment: { - id: reportedComments[0].id, - status: GQLCOMMENT_STATUS.APPROVED, - }, - moderationQueues: pureMerge(emptyModerationQueues, { - reported: { - count: 1, - }, - }), - }; - }); - - const moderationQueuesStub = pureMerge(emptyModerationQueues, { - reported: { - count: 2, - comments: createQueryResolverStub( - ({ variables }) => { - expectAndFail(variables).toEqual({ first: 5 }); - return { - edges: [ - { - node: reportedComments[0], - cursor: reportedComments[0].createdAt, - }, - { - node: reportedComments[1], - cursor: reportedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[1].createdAt, - hasNextPage: false, - }, - }; - } - ) as any, - }, - }); - - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: () => moderationQueuesStub, - }, - Mutation: { - approveComment: approveCommentStub, - }, - }), - }); - - const testID = `moderate-comment-${reportedComments[0].id}`; - const { getByTestID } = within(testRenderer.root); - const comment = await waitForElement(() => getByTestID(testID)); - - const ApproveButton = await waitForElement(() => - within(comment).getByLabelText("Approve") - ); - ApproveButton.props.onClick(); - - // Snapshot dangling state of comment. - expect(toJSON(comment)).toMatchSnapshot("dangling"); - - // Wait until comment is gone. - await waitUntilThrow(() => getByTestID(testID)); - - expect(approveCommentStub.called).toBe(true); - - // Count should have been updated. - expect( - toJSON(getByTestID("moderate-navigation-reported-count")) - ).toMatchSnapshot("count should be 1"); - }); - }); - - it("rejects comment in reported queue", async () => { - await act(async () => { - const rejectCommentStub = createMutationResolverStub< - MutationToRejectCommentResolver - >(({ variables }) => { - expectAndFail(variables).toMatchObject({ - commentID: reportedComments[0].id, - commentRevisionID: reportedComments[0].revision.id, - }); - return { - comment: { - id: reportedComments[0].id, - status: GQLCOMMENT_STATUS.REJECTED, - }, - moderationQueues: pureMerge(emptyModerationQueues, { - reported: { - count: 1, - }, - }), - }; - }); - - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - moderationQueues: () => - pureMerge(emptyModerationQueues, { - reported: { - count: 2, - comments: createQueryResolverStub< - ModerationQueueToCommentsResolver - >(({ variables }) => { - expectAndFail(variables).toEqual({ first: 5 }); - return { - edges: [ - { - node: reportedComments[0], - cursor: reportedComments[0].createdAt, - }, - { - node: reportedComments[1], - cursor: reportedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: reportedComments[1].createdAt, - hasNextPage: false, - }, - }; - }) as any, - }, - }), - }, - Mutation: { - rejectComment: rejectCommentStub, - }, - }), - }); - - const testID = `moderate-comment-${reportedComments[0].id}`; - const { getByTestID } = within(testRenderer.root); - const comment = await waitForElement(() => getByTestID(testID)); - - const RejectButton = await waitForElement(() => - within(comment).getByLabelText("Reject") - ); - RejectButton.props.onClick(); - - // Snapshot dangling state of comment. - expect(toJSON(comment)).toMatchSnapshot("dangling"); - - // Wait until comment is gone. - await waitUntilThrow(() => getByTestID(testID)); - - expect(rejectCommentStub.called).toBe(true); - - // Count should have been updated. - expect( - toJSON(getByTestID("moderate-navigation-reported-count")) - ).toMatchSnapshot("count should be 1"); - }); - }); -}); - -describe("rejected queue", () => { - beforeEach(() => { - replaceHistoryLocation(`http://localhost/admin/moderate/rejected`); - }); - - it("renders rejected queue with comments", async () => { - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - comments: ({ variables }) => { - expectAndFail(variables).toEqual({ - first: 5, - status: "REJECTED", - storyID: null, - }); - return { - edges: [ - { - node: rejectedComments[0], - cursor: rejectedComments[0].createdAt, - }, - { - node: rejectedComments[1], - cursor: rejectedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: rejectedComments[1].createdAt, - hasNextPage: false, - }, - }; - }, - }, - }), - }); - const { getByTestID } = within(testRenderer.root); - await waitForElement(() => getByTestID("moderate-container")); - expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot(); - }); - - it("shows a moderate story", async () => { - const { - testRenderer, - context: { transitionControl }, - } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - comments: ({ variables }) => { - expectAndFail(variables).toEqual({ - first: 5, - status: "REJECTED", - storyID: null, - }); - return { - edges: [ - { - node: rejectedComments[0], - cursor: rejectedComments[0].createdAt, - }, - { - node: rejectedComments[1], - cursor: rejectedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: rejectedComments[1].createdAt, - hasNextPage: false, - }, - }; - }, - }, - }), - }); - const moderateStory = await waitForElement( - () => within(testRenderer.root).getAllByText("Moderate Story")[0] - ); - transitionControl.allowTransition = false; - moderateStory.props.onClick({}); - // Expect a routing request was made to the right url. - expect(transitionControl.history[0].pathname).toBe( - `/admin/moderate/${reportedComments[0].story.id}` - ); - }); - - it("renders rejected queue with comments and load more", async () => { - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - comments: ({ variables, callCount }) => { - switch (callCount) { - case 0: - expectAndFail(variables).toEqual({ - first: 5, - status: GQLCOMMENT_STATUS.REJECTED, - storyID: null, - }); - return { - edges: [ - { - node: rejectedComments[0], - cursor: rejectedComments[0].createdAt, - }, - { - node: rejectedComments[1], - cursor: rejectedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: rejectedComments[1].createdAt, - hasNextPage: true, - }, - }; - default: - expectAndFail(variables).toEqual({ - first: 10, - after: rejectedComments[1].createdAt, - status: GQLCOMMENT_STATUS.REJECTED, - storyID: null, - }); - return { - edges: [ - { - node: rejectedComments[2], - cursor: rejectedComments[2].createdAt, - }, - ], - pageInfo: { - endCursor: rejectedComments[2].createdAt, - hasNextPage: false, - }, - }; - } - }, - }, - }), - }); - - const moderateContainer = await waitForElement(() => - within(testRenderer.root).getByTestID("moderate-container") - ); - - const { getByText, getAllByTestID, getByTestID } = within( - moderateContainer - ); - - // Get previous count of comments. - const previousCount = getAllByTestID(/^moderate-comment-.*$/).length; - - const loadMore = await waitForElement(() => getByText("Load More")); - loadMore.props.onClick(); - - // Wait for load more to disappear. - await waitUntilThrow(() => getByText("Load More")); - - // Verify we have one more item now. - const comments = getAllByTestID(/^moderate-comment-.*$/); - expect(comments.length).toBe(previousCount + 1); - - // Verify last one added was our new one - expect(comments[comments.length - 1].props["data-testid"]).toBe( - `moderate-comment-${rejectedComments[2].id}` - ); - - // Snapshot of added comment. - expect( - toJSON(getByTestID(`moderate-comment-${rejectedComments[2].id}`)) - ).toMatchSnapshot(); - }); - - it("approves comment in rejected queue", async () => { - const approveCommentStub = createMutationResolverStub< - MutationToApproveCommentResolver - >(({ variables }) => { - expectAndFail(variables).toMatchObject({ - commentID: rejectedComments[0].id, - commentRevisionID: rejectedComments[0].revision.id, - }); - return { - comment: { - id: rejectedComments[0].id, - status: GQLCOMMENT_STATUS.APPROVED, - }, - moderationQueues: pureMerge(emptyModerationQueues, { - reported: { - count: 1, - }, - }), - }; - }); - - const { testRenderer } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - comments: ({ variables }) => { - expectAndFail(variables).toEqual({ - first: 5, - status: "REJECTED", - storyID: null, - }); - return { - edges: [ - { - node: rejectedComments[0], - cursor: rejectedComments[0].createdAt, - }, - { - node: rejectedComments[1], - cursor: rejectedComments[1].createdAt, - }, - ], - pageInfo: { - endCursor: rejectedComments[1].createdAt, - hasNextPage: false, - }, - }; - }, - }, - Mutation: { - approveComment: approveCommentStub, - }, - }), - }); - - const testID = `moderate-comment-${rejectedComments[0].id}`; - const { getByTestID } = within(testRenderer.root); - const comment = await waitForElement(() => getByTestID(testID)); - - const ApproveButton = await waitForElement(() => - within(comment).getByLabelText("Approve") - ); - ApproveButton.props.onClick(); - - // Snapshot dangling state of comment. - expect(toJSON(getByTestID(testID))).toMatchSnapshot("dangling"); - - // Wait until comment is gone. - await waitUntilThrow(() => getByTestID(testID)); - - expect(approveCommentStub.called).toBe(true); - - // Count should have been updated. - expect( - toJSON(getByTestID("moderate-navigation-reported-count")) - ).toMatchSnapshot("count should be 1"); - }); -}); - -describe("single comment view", () => { - const comment = rejectedComments[0]; - const commentStub = createQueryResolverStub( - ({ variables }) => { - expectAndFail(variables).toEqual({ id: comment.id }); - return reportedComments[0]; - } - ); - - beforeEach(() => { - replaceHistoryLocation( - `http://localhost/admin/moderate/comment/${comment.id}` - ); - }); - - it("renders single comment view", async () => { - const { testRenderer } = await createTestRenderer({ - resolvers: { - Query: { - comment: commentStub, - }, - }, - }); - const { getByTestID } = within(testRenderer.root); - const container = await waitForElement(() => - getByTestID("single-moderate-container") - ); - expect(toJSON(container)).toMatchSnapshot(); - }); - - it("approves single comment", async () => { - const approveCommentStub = createMutationResolverStub< - MutationToApproveCommentResolver - >(({ variables }) => { - expectAndFail(variables).toMatchObject({ - commentID: comment.id, - commentRevisionID: comment.revision.id, - }); - return { - comment: { - id: comment.id, - status: GQLCOMMENT_STATUS.APPROVED, - }, - moderationQueues: emptyModerationQueues, - }; - }); - - const { testRenderer } = await createTestRenderer({ - resolvers: { - Query: { - comment: commentStub, - }, - Mutation: { - approveComment: approveCommentStub, - }, - }, - }); - - const { getByLabelText, getByTestID } = within(testRenderer.root); - const ApproveButton = await waitForElement(() => getByLabelText("Approve")); - ApproveButton.props.onClick(); - - expect( - toJSON(getByTestID(`moderate-comment-${comment.id}`)) - ).toMatchSnapshot(); - - expect(approveCommentStub.called).toBe(true); - }); - - it("rejects single comment", async () => { - const rejectCommentStub = createMutationResolverStub< - MutationToRejectCommentResolver - >(({ variables }) => { - expectAndFail(variables).toMatchObject({ - commentID: comment.id, - commentRevisionID: comment.revision.id, - }); - return { - comment: { - id: comment.id, - status: GQLCOMMENT_STATUS.REJECTED, - }, - moderationQueues: emptyModerationQueues, - }; - }); - - const { testRenderer } = await createTestRenderer({ - resolvers: { - Query: { - comment: commentStub, - }, - Mutation: { - rejectComment: rejectCommentStub, - }, - }, - }); - - const { getByLabelText, getByTestID } = within(testRenderer.root); - const RejectButton = await waitForElement(() => getByLabelText("Reject")); - RejectButton.props.onClick(); - - expect( - toJSON(getByTestID(`moderate-comment-${comment.id}`)) - ).toMatchSnapshot(); - expect(rejectCommentStub.called).toBe(true); - }); -}); diff --git a/src/core/client/admin/test/moderate/regularQueue.spec.tsx b/src/core/client/admin/test/moderate/regularQueue.spec.tsx new file mode 100644 index 000000000..4a4f370e4 --- /dev/null +++ b/src/core/client/admin/test/moderate/regularQueue.spec.tsx @@ -0,0 +1,554 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLCOMMENT_STATUS, + GQLResolver, + ModerationQueueToCommentsResolver, + MutationToApproveCommentResolver, + MutationToRejectCommentResolver, +} from "coral-framework/schema"; +import { + act, + createMutationResolverStub, + createQueryResolverStub, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + toJSON, + waitForElement, + waitUntilThrow, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + reportedComments, + settings, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { testRenderer, context, subscriptionHandler }; +} + +it("renders empty reported queue", async () => { + await act(async () => { + const { testRenderer } = await createTestRenderer(); + const { getByText } = within(testRenderer.root); + await waitForElement(() => getByText("no more reported", { exact: false })); + }); +}); + +it("renders empty pending queue", async () => { + replaceHistoryLocation("http://localhost/admin/moderate/pending"); + const { testRenderer } = await createTestRenderer(); + const { getByText } = within(testRenderer.root); + await waitForElement(() => getByText("no more pending", { exact: false })); +}); + +it("renders empty unmoderated queue", async () => { + replaceHistoryLocation("http://localhost/admin/moderate/unmoderated"); + const { testRenderer } = await createTestRenderer(); + const { getByText } = within(testRenderer.root); + await waitForElement(() => + getByText("comments have been moderated", { exact: false }) + ); +}); + +it("renders empty rejected queue", async () => { + replaceHistoryLocation("http://localhost/admin/moderate/rejected"); + const { testRenderer } = await createTestRenderer(); + const { getByText } = within(testRenderer.root); + await waitForElement(() => + getByText("no rejected comments", { exact: false }) + ); +}); + +it("renders reported queue with comments", async () => { + await act(async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: createQueryResolverStub< + ModerationQueueToCommentsResolver + >(({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: false, + }, + }; + }) as any, + }, + }), + }, + }), + }); + const { getByTestID } = within(testRenderer.root); + await waitForElement(() => getByTestID("moderate-container")); + expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot(); + }); +}); + +it("renders reported queue with comments", async () => { + await act(async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: createQueryResolverStub< + ModerationQueueToCommentsResolver + >(({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: false, + }, + }; + }), + }, + }), + }, + }), + }); + const { getByTestID } = within(testRenderer.root); + await waitForElement(() => getByTestID("moderate-container")); + expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot(); + }); +}); +it("show details of comment with flags", async () => { + await act(async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 1, + comments: createQueryResolverStub< + ModerationQueueToCommentsResolver + >(({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[0].createdAt, + hasNextPage: false, + }, + }; + }), + }, + }), + }, + }), + }); + const { getByTestID } = within(testRenderer.root); + const reported = await waitForElement(() => + getByTestID(`moderate-comment-${reportedComments[0].id}`) + ); + expect( + within(reported).queryByText( + reportedComments[0].flags.nodes[0].additionalDetails! + ) + ).toBeNull(); + within(reported) + .getByText("Details", { selector: "button" }) + .props.onClick(); + within(reported).getByText( + reportedComments[0].flags.nodes[0].additionalDetails! + ); + }); +}); +it("shows a moderate story", async () => { + await act(async () => { + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: createQueryResolverStub< + ModerationQueueToCommentsResolver + >(({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: false, + }, + }; + }) as any, + }, + }), + }, + }), + }); + const moderateStory = await waitForElement( + () => within(testRenderer.root).getAllByText("Moderate Story")[0] + ); + transitionControl.allowTransition = false; + moderateStory.props.onClick({}); + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe( + `/admin/moderate/${reportedComments[0].story.id}` + ); + }); +}); +it("renders reported queue with comments and load more", async () => { + await act(async () => { + const moderationQueuesStub = pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: createQueryResolverStub( + ({ variables, callCount }) => { + switch (callCount) { + case 0: + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: true, + }, + }; + default: + expectAndFail(variables).toEqual({ + first: 10, + after: reportedComments[1].createdAt, + }); + return { + edges: [ + { + node: reportedComments[2], + cursor: reportedComments[2].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[2].createdAt, + hasNextPage: false, + }, + }; + } + } + ) as any, + }, + }); + + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => moderationQueuesStub, + }, + }), + }); + const moderateContainer = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-container") + ); + + const { getByText, getAllByTestID, getByTestID } = within( + moderateContainer + ); + + // Get previous count of comments. + const previousCount = getAllByTestID(/^moderate-comment-.*$/).length; + + const loadMore = await waitForElement(() => getByText("Load More")); + loadMore.props.onClick(); + + // Wait for load more to disappear. + await waitUntilThrow(() => getByText("Load More")); + + // Verify we have one more item now. + const comments = getAllByTestID(/^moderate-comment-.*$/); + expect(comments.length).toBe(previousCount + 1); + + // Verify last one added was our new one + expect(comments[comments.length - 1].props["data-testid"]).toBe( + `moderate-comment-${reportedComments[2].id}` + ); + + // Snapshot of added comment. + expect( + toJSON(getByTestID(`moderate-comment-${reportedComments[2].id}`)) + ).toMatchSnapshot(); + }); +}); + +it("approves comment in reported queue", async () => { + await act(async () => { + const approveCommentStub = createMutationResolverStub< + MutationToApproveCommentResolver + >(({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: reportedComments[0].id, + commentRevisionID: reportedComments[0].revision.id, + }); + return { + comment: { + id: reportedComments[0].id, + status: GQLCOMMENT_STATUS.APPROVED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action", + author: { + id: viewer.id, + username: viewer.username, + }, + }, + }, + ], + }, + }, + moderationQueues: pureMerge(emptyModerationQueues, { + reported: { + count: 1, + }, + }), + }; + }); + + const moderationQueuesStub = pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: createQueryResolverStub( + ({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: false, + }, + }; + } + ) as any, + }, + }); + + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => moderationQueuesStub, + }, + Mutation: { + approveComment: approveCommentStub, + }, + }), + }); + + const testID = `moderate-comment-${reportedComments[0].id}`; + const { getByTestID } = within(testRenderer.root); + const comment = await waitForElement(() => getByTestID(testID)); + + const ApproveButton = await waitForElement(() => + within(comment).getByLabelText("Approve") + ); + ApproveButton.props.onClick(); + + // Snapshot dangling state of comment. + expect(toJSON(comment)).toMatchSnapshot("dangling"); + + // Wait until comment is gone. + await waitUntilThrow(() => getByTestID(testID)); + + expect(approveCommentStub.called).toBe(true); + + // Count should have been updated. + expect( + toJSON(getByTestID("moderate-navigation-reported-count")) + ).toMatchSnapshot("count should be 1"); + }); +}); + +it("rejects comment in reported queue", async () => { + await act(async () => { + const rejectCommentStub = createMutationResolverStub< + MutationToRejectCommentResolver + >(({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: reportedComments[0].id, + commentRevisionID: reportedComments[0].revision.id, + }); + return { + comment: { + id: reportedComments[0].id, + status: GQLCOMMENT_STATUS.REJECTED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action", + author: { + id: viewer.id, + username: viewer.username, + }, + }, + }, + ], + }, + }, + moderationQueues: pureMerge(emptyModerationQueues, { + reported: { + count: 1, + }, + }), + }; + }); + + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: createQueryResolverStub< + ModerationQueueToCommentsResolver + >(({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: false, + }, + }; + }) as any, + }, + }), + }, + Mutation: { + rejectComment: rejectCommentStub, + }, + }), + }); + + const testID = `moderate-comment-${reportedComments[0].id}`; + const { getByTestID } = within(testRenderer.root); + const comment = await waitForElement(() => getByTestID(testID)); + + const RejectButton = await waitForElement(() => + within(comment).getByLabelText("Reject") + ); + RejectButton.props.onClick(); + + // Snapshot dangling state of comment. + expect(toJSON(comment)).toMatchSnapshot("dangling"); + + // Wait until comment is gone. + await waitUntilThrow(() => getByTestID(testID)); + + expect(rejectCommentStub.called).toBe(true); + + // Count should have been updated. + expect( + toJSON(getByTestID("moderate-navigation-reported-count")) + ).toMatchSnapshot("count should be 1"); + }); +}); diff --git a/src/core/client/admin/test/moderate/regularQueueLiveNewComments.spec.tsx b/src/core/client/admin/test/moderate/regularQueueLiveNewComments.spec.tsx new file mode 100644 index 000000000..aed19966c --- /dev/null +++ b/src/core/client/admin/test/moderate/regularQueueLiveNewComments.spec.tsx @@ -0,0 +1,139 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLMODERATION_QUEUE, + GQLResolver, + SubscriptionToCommentEnteredModerationQueueResolver, + SubscriptionToCommentLeftModerationQueueResolver, +} from "coral-framework/schema"; +import { + act, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + reportedComments, + settings, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + replaceHistoryLocation(`http://localhost/admin/moderate/reported`); + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-main-container") + ); + + return { testRenderer, context, container, subscriptionHandler }; +} + +it("allows viewing new when new comments come in", async () => { + const { subscriptionHandler, container } = await createTestRenderer(); + const commentData = reportedComments[0]; + expect(subscriptionHandler.has("commentEnteredModerationQueue")).toBe(true); + + subscriptionHandler.dispatch< + SubscriptionToCommentEnteredModerationQueueResolver + >("commentEnteredModerationQueue", variables => { + if ( + variables.storyID !== null || + variables.queue !== GQLMODERATION_QUEUE.REPORTED + ) { + return; + } + return { + queue: GQLMODERATION_QUEUE.REPORTED, + comment: commentData, + }; + }); + + const viewNewButton = await waitForElement(() => + within(container).getByText("View 1 new", { + exact: false, + selector: "button", + }) + ); + act(() => { + viewNewButton.props.onClick(); + }); + // View New Button should disappear. + expect(() => within(container).getByText(/View \d+ new/)).toThrow(); + // New comment should appear. + within(container).getByTestID(`moderate-comment-${commentData.id}`); +}); + +it("recognizes when same comment enters and leaves again", async () => { + const { subscriptionHandler, container } = await createTestRenderer(); + const commentData = reportedComments[0]; + expect(subscriptionHandler.has("commentEnteredModerationQueue")).toBe(true); + + subscriptionHandler.dispatch< + SubscriptionToCommentEnteredModerationQueueResolver + >("commentEnteredModerationQueue", variables => { + if ( + variables.storyID !== null || + variables.queue !== GQLMODERATION_QUEUE.REPORTED + ) { + return; + } + return { + queue: GQLMODERATION_QUEUE.REPORTED, + comment: commentData, + }; + }); + + await waitForElement(() => + within(container).getByText(/View \d+ new/, { + exact: false, + selector: "button", + }) + ); + + subscriptionHandler.dispatch< + SubscriptionToCommentLeftModerationQueueResolver + >("commentLeftModerationQueue", variables => { + if ( + variables.storyID !== null || + variables.queue !== GQLMODERATION_QUEUE.REPORTED + ) { + return; + } + return { + queue: GQLMODERATION_QUEUE.REPORTED, + comment: commentData, + }; + }); + + // View New Button should disappear. + expect(() => within(container).getByText(/View \d+ new/)).toThrow(); +}); diff --git a/src/core/client/admin/test/moderate/regularQueueLiveStatus.spec.tsx b/src/core/client/admin/test/moderate/regularQueueLiveStatus.spec.tsx new file mode 100644 index 000000000..391c52ad0 --- /dev/null +++ b/src/core/client/admin/test/moderate/regularQueueLiveStatus.spec.tsx @@ -0,0 +1,126 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLCOMMENT_STATUS, + GQLMODERATION_QUEUE, + GQLResolver, + ModerationQueueToCommentsResolver, + SubscriptionToCommentLeftModerationQueueResolver, +} from "coral-framework/schema"; +import { + createQueryResolverStub, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + reportedComments, + settings, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; +const commentData = reportedComments[0]; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + replaceHistoryLocation(`http://localhost/admin/moderate/reported`); + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 1, + comments: createQueryResolverStub< + ModerationQueueToCommentsResolver + >(({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: commentData, + cursor: commentData.createdAt, + }, + ], + pageInfo: { + endCursor: commentData.createdAt, + hasNextPage: false, + }, + }; + }), + }, + }), + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-main-container") + ); + + const comment = within(container).getByTestID( + `moderate-comment-${commentData.id}` + ); + + return { testRenderer, context, container, comment, subscriptionHandler }; +} + +it("update comment status live", async () => { + const { subscriptionHandler, comment } = await createTestRenderer(); + expect(subscriptionHandler.has("commentLeftModerationQueue")).toBe(true); + expect(() => + within(comment).getByText("Moderated By", { exact: false }) + ).toThrow(); + + subscriptionHandler.dispatch< + SubscriptionToCommentLeftModerationQueueResolver + >("commentLeftModerationQueue", variables => { + if ( + variables.storyID !== null || + variables.queue !== GQLMODERATION_QUEUE.REPORTED + ) { + return; + } + return { + queue: GQLMODERATION_QUEUE.REPORTED, + comment: pureMerge(commentData, { + status: GQLCOMMENT_STATUS.APPROVED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action-1", + moderator: users.moderators[0], + status: GQLCOMMENT_STATUS.APPROVED, + }, + }, + ], + }, + }), + }; + }); + + await waitForElement(() => + within(comment).getByText("Moderated By", { exact: false }) + ); +}); diff --git a/src/core/client/admin/test/moderate/rejectedQueue.spec.tsx b/src/core/client/admin/test/moderate/rejectedQueue.spec.tsx new file mode 100644 index 000000000..02751cdd6 --- /dev/null +++ b/src/core/client/admin/test/moderate/rejectedQueue.spec.tsx @@ -0,0 +1,314 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLCOMMENT_STATUS, + GQLResolver, + MutationToApproveCommentResolver, +} from "coral-framework/schema"; +import { + createMutationResolverStub, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + toJSON, + waitForElement, + waitUntilThrow, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + rejectedComments, + reportedComments, + settings, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { testRenderer, context, subscriptionHandler }; +} + +beforeEach(() => { + replaceHistoryLocation(`http://localhost/admin/moderate/rejected`); +}); + +it("renders rejected queue with comments", async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + comments: ({ variables }) => { + expectAndFail(variables).toEqual({ + first: 5, + status: "REJECTED", + storyID: null, + }); + return { + edges: [ + { + node: rejectedComments[0], + cursor: rejectedComments[0].createdAt, + }, + { + node: rejectedComments[1], + cursor: rejectedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: rejectedComments[1].createdAt, + hasNextPage: false, + }, + }; + }, + }, + }), + }); + const { getByTestID } = within(testRenderer.root); + await waitForElement(() => getByTestID("moderate-container")); + expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot(); +}); + +it("shows a moderate story", async () => { + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + comments: ({ variables }) => { + expectAndFail(variables).toEqual({ + first: 5, + status: "REJECTED", + storyID: null, + }); + return { + edges: [ + { + node: rejectedComments[0], + cursor: rejectedComments[0].createdAt, + }, + { + node: rejectedComments[1], + cursor: rejectedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: rejectedComments[1].createdAt, + hasNextPage: false, + }, + }; + }, + }, + }), + }); + const moderateStory = await waitForElement( + () => within(testRenderer.root).getAllByText("Moderate Story")[0] + ); + transitionControl.allowTransition = false; + moderateStory.props.onClick({}); + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe( + `/admin/moderate/${reportedComments[0].story.id}` + ); +}); + +it("renders rejected queue with comments and load more", async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + comments: ({ variables, callCount }) => { + switch (callCount) { + case 0: + expectAndFail(variables).toEqual({ + first: 5, + status: GQLCOMMENT_STATUS.REJECTED, + storyID: null, + }); + return { + edges: [ + { + node: rejectedComments[0], + cursor: rejectedComments[0].createdAt, + }, + { + node: rejectedComments[1], + cursor: rejectedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: rejectedComments[1].createdAt, + hasNextPage: true, + }, + }; + default: + expectAndFail(variables).toEqual({ + first: 10, + after: rejectedComments[1].createdAt, + status: GQLCOMMENT_STATUS.REJECTED, + storyID: null, + }); + return { + edges: [ + { + node: rejectedComments[2], + cursor: rejectedComments[2].createdAt, + }, + ], + pageInfo: { + endCursor: rejectedComments[2].createdAt, + hasNextPage: false, + }, + }; + } + }, + }, + }), + }); + + const moderateContainer = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-container") + ); + + const { getByText, getAllByTestID, getByTestID } = within(moderateContainer); + + // Get previous count of comments. + const previousCount = getAllByTestID(/^moderate-comment-.*$/).length; + + const loadMore = await waitForElement(() => getByText("Load More")); + loadMore.props.onClick(); + + // Wait for load more to disappear. + await waitUntilThrow(() => getByText("Load More")); + + // Verify we have one more item now. + const comments = getAllByTestID(/^moderate-comment-.*$/); + expect(comments.length).toBe(previousCount + 1); + + // Verify last one added was our new one + expect(comments[comments.length - 1].props["data-testid"]).toBe( + `moderate-comment-${rejectedComments[2].id}` + ); + + // Snapshot of added comment. + expect( + toJSON(getByTestID(`moderate-comment-${rejectedComments[2].id}`)) + ).toMatchSnapshot(); +}); + +it("approves comment in rejected queue", async () => { + const approveCommentStub = createMutationResolverStub< + MutationToApproveCommentResolver + >(({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: rejectedComments[0].id, + commentRevisionID: rejectedComments[0].revision.id, + }); + return { + comment: { + id: rejectedComments[0].id, + status: GQLCOMMENT_STATUS.APPROVED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action", + author: { + id: viewer.id, + username: viewer.username, + }, + }, + }, + ], + }, + }, + moderationQueues: pureMerge(emptyModerationQueues, { + reported: { + count: 1, + }, + }), + }; + }); + + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + comments: ({ variables }) => { + expectAndFail(variables).toEqual({ + first: 5, + status: "REJECTED", + storyID: null, + }); + return { + edges: [ + { + node: rejectedComments[0], + cursor: rejectedComments[0].createdAt, + }, + { + node: rejectedComments[1], + cursor: rejectedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: rejectedComments[1].createdAt, + hasNextPage: false, + }, + }; + }, + }, + Mutation: { + approveComment: approveCommentStub, + }, + }), + }); + + const testID = `moderate-comment-${rejectedComments[0].id}`; + const { getByTestID } = within(testRenderer.root); + const comment = await waitForElement(() => getByTestID(testID)); + + const ApproveButton = await waitForElement(() => + within(comment).getByLabelText("Approve") + ); + ApproveButton.props.onClick(); + + // Snapshot dangling state of comment. + expect(toJSON(getByTestID(testID))).toMatchSnapshot("dangling"); + + // Wait until comment is gone. + await waitUntilThrow(() => getByTestID(testID)); + + expect(approveCommentStub.called).toBe(true); + + // Count should have been updated. + expect( + toJSON(getByTestID("moderate-navigation-reported-count")) + ).toMatchSnapshot("count should be 1"); +}); diff --git a/src/core/client/admin/test/moderate/searchBar.spec.tsx b/src/core/client/admin/test/moderate/searchBar.spec.tsx new file mode 100644 index 000000000..41416b6c9 --- /dev/null +++ b/src/core/client/admin/test/moderate/searchBar.spec.tsx @@ -0,0 +1,264 @@ +import { noop } from "lodash"; +import { ReactTestInstance, ReactTestRenderer } from "react-test-renderer"; + +import { pureMerge } from "coral-common/utils"; +import { GQLResolver } from "coral-framework/schema"; +import { + act, + createResolversStub, + CreateTestRendererParams, + findParentWithType, + replaceHistoryLocation, + wait, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + emptyStories, + settings, + stories, + storyConnection, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { testRenderer, context, subscriptionHandler }; +} + +const openSearchBar = async (testRenderer: ReactTestRenderer) => { + await act(async () => { + await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-searchBar-container") + ); + }); + const searchBar = within(testRenderer.root).getByTestID( + "moderate-searchBar-container" + ); + const textField = within(searchBar).getByLabelText( + "Search or jump to story..." + ); + const form = findParentWithType(textField, "form")!; + act(() => textField.props.onFocus({})); + return { searchBar, textField, form }; +}; + +describe("all stories", () => { + it("renders search bar", async () => { + let searchBar: ReactTestInstance; + await act(async () => { + const { testRenderer } = await createTestRenderer(); + searchBar = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-searchBar-container") + ); + }); + expect(within(searchBar!).toJSON()).toMatchSnapshot(); + }); + + describe("active", () => { + it("search with no results", async () => { + const query = "InterestingStory"; + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + stories: ({ variables }) => { + expectAndFail(variables.query).toBe(query); + return emptyStories; + }, + }, + }), + }); + const { searchBar, textField, form } = await openSearchBar(testRenderer); + expect(within(searchBar).toJSON()).toMatchSnapshot(); + + await act(async () => { + // Search for sth. + textField.props.onChange(query); + form.props.onSubmit(); + + // Ensure no results message is shown. + await wait(() => + within(searchBar).getByText("No results", { exact: false }) + ); + }); + + act(() => { + // Blurring should close the listbox. + textField.props.onBlur({}); + }); + expect(within(searchBar).queryByText("No results")).toBeNull(); + }); + it("search with actual results", async () => { + const query = "InterestingStory"; + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + stories: ({ variables }) => { + expectAndFail(variables.query).toBe(query); + return storyConnection; + }, + }, + }), + }); + transitionControl.allowTransition = false; + const { searchBar, textField, form } = await openSearchBar(testRenderer); + + const story = storyConnection.edges[0].node; + + let storyOption: ReactTestInstance; + + await act(async () => { + // Search for sth. + textField.props.onChange(query); + form.props.onSubmit(); + + // Find the story in the search results. + storyOption = findParentWithType( + await waitForElement(() => + within(searchBar).getByText(story.metadata!.title!, { + exact: false, + }) + ), + "li" + )!; + }); + + // Go to story. + storyOption!.props.onClick({ button: 0, preventDefault: noop }); + + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe( + `/admin/moderate/${story.id}` + ); + }); + it("search with too many results", async () => { + const query = "InterestingStory"; + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + stories: ({ variables }) => { + expectAndFail(variables.query).toBe(query); + return pureMerge(storyConnection, { + pageInfo: { hasNextPage: true }, + }); + }, + }, + }), + }); + transitionControl.allowTransition = false; + const { searchBar, textField, form } = await openSearchBar(testRenderer); + + let seeAllOption: ReactTestInstance; + await act(async () => { + // Search for sth. + textField.props.onChange(query); + form.props.onSubmit(); + + // Find see all options in the search results. + seeAllOption = findParentWithType( + await waitForElement(() => + within(searchBar).getByText("See all results", { exact: false }) + ), + "li" + )!; + }); + + expect(within(seeAllOption!).toJSON()).toMatchSnapshot(); + + // Go to story. + seeAllOption!.props.onClick({ button: 0, preventDefault: noop }); + + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe("/admin/stories"); + expect(transitionControl.history[0].search).toBe(`?q=${query}`); + }); + }); +}); +describe("specified story", () => { + beforeEach(() => { + replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`); + }); + it("renders search bar", async () => { + await act(async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + story: () => stories[0], + }, + }), + }); + const searchBar = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-searchBar-container") + ); + const textField = within(searchBar).getByLabelText( + "Search or jump to story..." + ); + expect(textField.props.placeholder).toBe(stories[0].metadata!.title); + }); + }); + it("shows moderate all option", async () => { + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + story: () => stories[0], + }, + }), + }); + transitionControl.allowTransition = false; + const { searchBar } = await openSearchBar(testRenderer); + + // Find see all options in the search results. + const moderateAllOptions = findParentWithType( + await waitForElement(() => + within(searchBar).getByText("Moderate all", { exact: false }) + ), + "li" + )!; + + // Activate moderate all. + moderateAllOptions.props.onClick({ button: 0, preventDefault: noop }); + + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe("/admin/moderate"); + }); +}); diff --git a/src/core/client/admin/test/moderate/singleComment.spec.tsx b/src/core/client/admin/test/moderate/singleComment.spec.tsx new file mode 100644 index 000000000..3f9457793 --- /dev/null +++ b/src/core/client/admin/test/moderate/singleComment.spec.tsx @@ -0,0 +1,189 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLCOMMENT_STATUS, + MutationToApproveCommentResolver, + MutationToRejectCommentResolver, + QueryToCommentResolver, +} from "coral-framework/schema"; +import { GQLResolver } from "coral-framework/schema"; +import { + createMutationResolverStub, + createQueryResolverStub, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + toJSON, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + rejectedComments, + reportedComments, + settings, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { testRenderer, context, subscriptionHandler }; +} + +const comment = rejectedComments[0]; +const commentStub = createQueryResolverStub( + ({ variables }) => { + expectAndFail(variables).toEqual({ id: comment.id }); + return reportedComments[0]; + } +); + +beforeEach(() => { + replaceHistoryLocation( + `http://localhost/admin/moderate/comment/${comment.id}` + ); +}); + +it("renders single comment view", async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: { + Query: { + comment: commentStub, + }, + }, + }); + const { getByTestID } = within(testRenderer.root); + const container = await waitForElement(() => + getByTestID("single-moderate-container") + ); + expect(toJSON(container)).toMatchSnapshot(); +}); + +it("approves single comment", async () => { + const approveCommentStub = createMutationResolverStub< + MutationToApproveCommentResolver + >(({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: comment.id, + commentRevisionID: comment.revision.id, + }); + return { + comment: { + id: comment.id, + status: GQLCOMMENT_STATUS.APPROVED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action", + author: { + id: viewer.id, + username: viewer.username, + }, + }, + }, + ], + }, + }, + moderationQueues: emptyModerationQueues, + }; + }); + + const { testRenderer } = await createTestRenderer({ + resolvers: { + Query: { + comment: commentStub, + }, + Mutation: { + approveComment: approveCommentStub, + }, + }, + }); + + const { getByLabelText, getByTestID } = within(testRenderer.root); + const ApproveButton = await waitForElement(() => getByLabelText("Approve")); + ApproveButton.props.onClick(); + + expect( + toJSON(getByTestID(`moderate-comment-${comment.id}`)) + ).toMatchSnapshot(); + + expect(approveCommentStub.called).toBe(true); +}); + +it("rejects single comment", async () => { + const rejectCommentStub = createMutationResolverStub< + MutationToRejectCommentResolver + >(({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: comment.id, + commentRevisionID: comment.revision.id, + }); + return { + comment: { + id: comment.id, + status: GQLCOMMENT_STATUS.REJECTED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action", + author: { + id: viewer.id, + username: viewer.username, + }, + }, + }, + ], + }, + }, + moderationQueues: emptyModerationQueues, + }; + }); + + const { testRenderer } = await createTestRenderer({ + resolvers: { + Query: { + comment: commentStub, + }, + Mutation: { + rejectComment: rejectCommentStub, + }, + }, + }); + + const { getByLabelText, getByTestID } = within(testRenderer.root); + const RejectButton = await waitForElement(() => getByLabelText("Reject")); + RejectButton.props.onClick(); + + expect( + toJSON(getByTestID(`moderate-comment-${comment.id}`)) + ).toMatchSnapshot(); + expect(rejectCommentStub.called).toBe(true); +}); diff --git a/src/core/client/admin/test/moderate/singleCommentLiveStatus.spec.tsx b/src/core/client/admin/test/moderate/singleCommentLiveStatus.spec.tsx new file mode 100644 index 000000000..7b69ec857 --- /dev/null +++ b/src/core/client/admin/test/moderate/singleCommentLiveStatus.spec.tsx @@ -0,0 +1,99 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLCOMMENT_STATUS, + GQLResolver, + SubscriptionToCommentStatusUpdatedResolver, +} from "coral-framework/schema"; +import { + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { reportedComments, settings, users } from "../fixtures"; + +const viewer = users.admins[0]; +const commentData = reportedComments[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + replaceHistoryLocation( + `http://localhost/admin/moderate/comment/${commentData.id}` + ); + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + comment: () => commentData, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("single-moderate-container") + ); + + const comment = within(container).getByTestID( + `moderate-comment-${commentData.id}` + ); + + return { testRenderer, context, container, comment, subscriptionHandler }; +} + +it("update comment status live", async () => { + const { subscriptionHandler, comment } = await createTestRenderer(); + expect(subscriptionHandler.has("commentStatusUpdated")).toBe(true); + expect(() => + within(comment).getByText("Moderated By", { exact: false }) + ).toThrow(); + + subscriptionHandler.dispatch( + "commentStatusUpdated", + variables => { + if (variables.id !== commentData.id) { + return; + } + return { + newStatus: GQLCOMMENT_STATUS.APPROVED, + comment: pureMerge(commentData, { + status: GQLCOMMENT_STATUS.APPROVED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action-1", + moderator: users.moderators[0], + status: GQLCOMMENT_STATUS.APPROVED, + }, + }, + ], + }, + }), + }; + } + ); + + // When status was changed by another user, the moderated by info should appear. + await waitForElement(() => + within(comment).getByText("Moderated By", { exact: false }) + ); +}); diff --git a/src/core/client/admin/test/moderate/singleStory.spec.tsx b/src/core/client/admin/test/moderate/singleStory.spec.tsx new file mode 100644 index 000000000..9bba35c1d --- /dev/null +++ b/src/core/client/admin/test/moderate/singleStory.spec.tsx @@ -0,0 +1,69 @@ +import { pureMerge } from "coral-common/utils"; +import { GQLResolver } from "coral-framework/schema"; +import { + act, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + settings, + stories, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { testRenderer, context, subscriptionHandler }; +} + +it("passes storyID to the endpoints", async () => { + replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`); + await act(async () => { + await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: ({ variables }) => { + expectAndFail(variables.storyID).toBe(stories[0].id); + return emptyModerationQueues; + }, + comments: ({ variables }) => { + expectAndFail(variables.storyID).toBe(stories[0].id); + return emptyRejectedComments; + }, + }, + }), + }); + }); +}); diff --git a/src/core/client/admin/test/moderate/tabBar.spec.tsx b/src/core/client/admin/test/moderate/tabBar.spec.tsx new file mode 100644 index 000000000..7b35c90a0 --- /dev/null +++ b/src/core/client/admin/test/moderate/tabBar.spec.tsx @@ -0,0 +1,74 @@ +import { pureMerge } from "coral-common/utils"; +import { GQLResolver } from "coral-framework/schema"; +import { + act, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + toJSON, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + settings, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => emptyRejectedComments, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(true, "loggedIn"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { testRenderer, context, subscriptionHandler }; +} + +describe("tab bar", () => { + it("renders tab bar (empty queues)", async () => { + await act(async () => { + const { testRenderer } = await createTestRenderer(); + const { getByTestID } = within(testRenderer.root); + await waitForElement(() => getByTestID("moderate-container")); + expect( + toJSON(getByTestID("moderate-tabBar-container")) + ).toMatchSnapshot(); + }); + }); + it("should not show moderate story link in comment cards", async () => { + await act(async () => { + const { testRenderer } = await createTestRenderer(); + const { getByTestID } = within(testRenderer.root); + await waitForElement(() => getByTestID("moderate-container")); + expect( + within(testRenderer.root).queryByText("Moderate Story") + ).toBeNull(); + }); + }); +}); diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx index c7824ecd4..67199e5d6 100644 --- a/src/core/client/framework/lib/bootstrap/createManaged.tsx +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -5,7 +5,7 @@ import { Child as PymChild } from "pym.js"; import React, { Component, ComponentType } from "react"; import { Formatter } from "react-timeago"; import { Environment, RecordSource, Store } from "relay-runtime"; -import uuid from "uuid/v4"; +import uuid from "uuid/v1"; import { getBrowserInfo } from "coral-framework/lib/browserInfo"; import { LOCAL_ID } from "coral-framework/lib/relay"; @@ -21,7 +21,12 @@ import { RestClient } from "coral-framework/lib/rest"; import { ClickFarAwayRegister } from "coral-ui/components/ClickOutside"; import { generateBundles, LocalesData, negotiateLanguages } from "../i18n"; -import { createNetwork, TokenGetter } from "../network"; +import { + createManagedSubscriptionClient, + createNetwork, + ManagedSubscriptionClient, + TokenGetter, +} from "../network"; import { PostMessageService } from "../postMessage"; import { CoralContext, CoralContextProvider } from "./CoralContext"; import SendPymReady from "./SendPymReady"; @@ -48,6 +53,11 @@ interface CreateContextArguments { eventEmitter?: EventEmitter2; } +/** websocketURL points to our live graphql server */ +const websocketURL = `${location.protocol === "https:" ? "wss" : "ws"}://${ + location.hostname +}:${location.port}/api/graphql/live`; + /** * timeagoFormatter integrates timeago into our translation * framework. It gets injected into the UIContext. @@ -87,8 +97,10 @@ function areWeInIframe() { } } -function createRelayEnvironment() { - // Initialize Relay. +function createRelayEnvironment( + subscriptionClient: ManagedSubscriptionClient, + clientID: string +) { const source = new RecordSource(); const tokenGetter: TokenGetter = () => { const localState = source.get(LOCAL_ID); @@ -98,14 +110,14 @@ function createRelayEnvironment() { return ""; }; const environment = new Environment({ - network: createNetwork(tokenGetter), + network: createNetwork(subscriptionClient, tokenGetter, clientID), store: new Store(source), }); - return { environment, tokenGetter }; + return { environment, tokenGetter, subscriptionClient }; } -function createRestClient(tokenGetter: () => string) { - return new RestClient("/api", tokenGetter); +function createRestClient(tokenGetter: () => string, clientID: string) { + return new RestClient("/api", tokenGetter, clientID); } /** @@ -114,6 +126,8 @@ function createRestClient(tokenGetter: () => string) { */ function createMangedCoralContextProvider( context: CoralContext, + subscriptionClient: ManagedSubscriptionClient, + clientID: string, initLocalState: InitLocalState ) { const ManagedCoralContextProvider = class extends Component< @@ -135,25 +149,40 @@ function createMangedCoralContextProvider( // Clear session storage. this.state.context.sessionStorage.clear(); + // Pause subscriptions. + subscriptionClient.pause(); + // Create a new context with a new Relay Environment. const { environment: newEnvironment, tokenGetter: newTokenGetter, - } = createRelayEnvironment(); + } = createRelayEnvironment(subscriptionClient, clientID); const newContext = { ...this.state.context, relayEnvironment: newEnvironment, - rest: createRestClient(newTokenGetter), + rest: createRestClient(newTokenGetter, clientID), }; // Initialize local state. await initLocalState(newContext.relayEnvironment, newContext); + // Set new token for the websocket connection. + // TODO: (cvle) dynamically reset when token changes. + // ^ only necessary when we can prolong existing session using + // a new token. + subscriptionClient.setAccessToken(newTokenGetter()); + // Propagate new context. - this.setState({ - context: newContext, - }); + this.setState( + { + context: newContext, + }, + () => { + // Resume subscriptions after context has changed. + subscriptionClient.resume(); + } + ); }; public render() { @@ -233,7 +262,18 @@ export default async function createManaged({ const localStorage = resolveLocalStorage(pym); const sessionStorage = resolveSessionStorage(pym); - const { environment, tokenGetter } = createRelayEnvironment(); + /** clientID is sent to the server with every request */ + const clientID = uuid(); + + const subscriptionClient = createManagedSubscriptionClient( + websocketURL, + clientID + ); + + const { environment, tokenGetter } = createRelayEnvironment( + subscriptionClient, + clientID + ); // Assemble context. const context: CoralContext = { @@ -244,7 +284,7 @@ export default async function createManaged({ pym, eventEmitter, registerClickFarAway, - rest: createRestClient(tokenGetter), + rest: createRestClient(tokenGetter, clientID), postMessage: new PostMessageService(), localStorage, sessionStorage, @@ -258,7 +298,18 @@ export default async function createManaged({ // Initialize local state. await initLocalState(context.relayEnvironment, context); + // Set current token for the websocket connection. + // TODO: (cvle) dynamically reset when token changes. + // ^ only necessary when we can prolong existing session using + // a new token. + subscriptionClient.setAccessToken(tokenGetter()); + // Returns a managed CoralContextProvider, that includes the above // context and handles context changes, e.g. when a user session changes. - return createMangedCoralContextProvider(context, initLocalState); + return createMangedCoralContextProvider( + context, + subscriptionClient, + clientID, + initLocalState + ); } diff --git a/src/core/client/framework/lib/network/clientIDMiddleware.ts b/src/core/client/framework/lib/network/clientIDMiddleware.ts new file mode 100644 index 000000000..d8a6b2ffe --- /dev/null +++ b/src/core/client/framework/lib/network/clientIDMiddleware.ts @@ -0,0 +1,20 @@ +import { Middleware } from "react-relay-network-modern/es"; + +import { CLIENT_ID_HEADER } from "coral-common/constants"; + +/** + * Sets clientID on the header. + * + * @param clientID an identifier for this client. + */ +const clientIDMiddleware: ( + clientID: string +) => Middleware = clientID => next => async req => { + if (!req.fetchOpts.headers) { + req.fetchOpts.headers = {}; + } + req.fetchOpts.headers[CLIENT_ID_HEADER] = clientID; + return next(req); +}; + +export default clientIDMiddleware; diff --git a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts new file mode 100644 index 000000000..edfc6bf17 --- /dev/null +++ b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts @@ -0,0 +1,179 @@ +import { + CacheConfig, + ConcreteBatch, + Disposable, + Variables, +} from "react-relay-network-modern/es"; +import { SubscriptionClient } from "subscriptions-transport-ws"; + +import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants"; + +/** + * SubscriptionRequest containts the subscription + * request data that comes from Relay. + */ +export interface SubscriptionRequest { + operation: ConcreteBatch; + variables: Variables; + cacheConfig: CacheConfig; + observer: any; + subscribe: () => void; + unsubscribe: (() => void) | null; +} + +/** + * ManagedSubscriptionClient builts on top of `SubscriptionClient` + * and manages the websocket connection economically. A connection is + * only establish when there is at least 1 active susbcription and closes + * when there is no more active subscriptions. + */ +export interface ManagedSubscriptionClient { + /** + * Susbcribe to a GraphQL subscription, this is usually called from + * the SubscriptionFunction provided to Relay. + */ + subscribe( + operation: ConcreteBatch, + variables: Variables, + cacheConfig: CacheConfig, + observer: any + ): Disposable; + /** Pauses all active subscriptions causing websocket connection to close. */ + pause(): void; + /** Resume all subscriptions eventually causing websocket to start with new connection parameters */ + resume(): void; + /** Sets access token and restarts the websocket connection */ + setAccessToken(accessToken: string): void; +} + +/** + * Creates a ManagedSubscriptionClient + * @param url url of the graphql live server + * @param clientID a clientID that is provided to the graphql live server + */ +export default function createManagedSubscriptionClient( + url: string, + clientID: string +): ManagedSubscriptionClient { + const requests: SubscriptionRequest[] = []; + let subscriptionClient: SubscriptionClient | null = null; + let paused = false; + let accessToken = ""; + + const closeClient = () => { + if (subscriptionClient) { + subscriptionClient.close(); + // Stop current retry attempt. + // TODO: (cvle) This relies on internals. + (subscriptionClient as any).clearMaxConnectTimeout(); + (subscriptionClient as any).clearTryReconnectTimeout(); + subscriptionClient = null; + } + }; + + const subscribe = ( + operation: ConcreteBatch, + variables: Variables, + cacheConfig: CacheConfig, + observer: any + ) => { + // Capture request into an `SubscriptionRequest` object. + const request: Partial = { + operation, + variables, + cacheConfig, + observer, + }; + request.subscribe = () => { + if (!subscriptionClient) { + subscriptionClient = new SubscriptionClient(url, { + reconnect: true, + connectionParams: { + [ACCESS_TOKEN_PARAM]: accessToken, + [CLIENT_ID_PARAM]: clientID, + }, + }); + } + const subscription = subscriptionClient + .request({ + operationName: operation.name, + query: operation.text!, + variables, + }) + .subscribe({ + next({ data }) { + observer.onNext({ data }); + }, + }); + request.unsubscribe = () => { + subscription.unsubscribe(); + }; + }; + // Register the request. + requests.push(request as SubscriptionRequest); + + // Start susbcription if we are not paused. + if (!paused) { + request.subscribe(); + } + return { + dispose: () => { + const i = requests.findIndex(r => r === request); + if (i !== -1) { + // Unsubscribe if available. + if (request.unsubscribe) { + request.unsubscribe(); + } + // Remove from requests list. + requests.splice(i, 1); + + // Close client if there is no active subscription. + if ( + subscriptionClient && + (requests.length === 0 || requests.every(r => !r.unsubscribe)) + ) { + closeClient(); + } + } + }, + }; + }; + + const pause = () => { + paused = true; + // Unsubscribe from all active subscriptions. + for (const r of requests) { + if (r.unsubscribe) { + r.unsubscribe(); + r.unsubscribe = null; + } + } + // Close websocket conncetion. + closeClient(); + }; + + const resume = () => { + // Resume all subscriptions. + for (const r of requests) { + if (!r.unsubscribe) { + r.subscribe(); + } + } + paused = false; + }; + + const setAccessToken = (t: string) => { + accessToken = t; + if (!paused) { + pause(); + resume(); + } + }; + + return Object.freeze({ + subscribe, + pause, + resume, + setAccessToken, + }); +} diff --git a/src/core/client/framework/lib/network/createNetwork.ts b/src/core/client/framework/lib/network/createNetwork.ts index 77adee251..62b2b2d45 100644 --- a/src/core/client/framework/lib/network/createNetwork.ts +++ b/src/core/client/framework/lib/network/createNetwork.ts @@ -4,39 +4,69 @@ import { cacheMiddleware, RelayNetworkLayer, retryMiddleware, + SubscribeFunction, urlMiddleware, } from "react-relay-network-modern/es"; +import clientIDMiddleware from "./clientIDMiddleware"; +import { ManagedSubscriptionClient } from "./createManagedSubscriptionClient"; import customErrorMiddleware from "./customErrorMiddleware"; export type TokenGetter = () => string; const graphqlURL = "/api/graphql"; -export default function createNetwork(tokenGetter: TokenGetter) { - return new RelayNetworkLayer([ - customErrorMiddleware, - cacheMiddleware({ - size: 100, // max 100 requests - ttl: 900000, // 15 minutes - clearOnMutation: true, - }), - urlMiddleware({ - url: req => Promise.resolve(graphqlURL), - }), - batchMiddleware({ - batchUrl: (requestMap: any) => Promise.resolve(graphqlURL), - batchTimeout: 0, - allowMutations: true, - }), - retryMiddleware({ - fetchTimeout: 15000, - retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100, - // or simple array [3200, 6400, 12800, 25600, 51200, 102400, 204800, 409600], - statusCodes: [500, 503, 504], - }), - authMiddleware({ - token: tokenGetter, - }), - ]); +function createSubscriptionFunction( + subscriptionClient: ManagedSubscriptionClient +): SubscribeFunction { + const fn: SubscribeFunction = ( + operation, + variables, + cacheConfig, + observer + ) => { + return subscriptionClient.subscribe( + operation, + variables, + cacheConfig, + observer + ); + }; + return fn; +} + +export default function createNetwork( + subscriptionClient: ManagedSubscriptionClient, + tokenGetter: TokenGetter, + clientID: string +) { + return new RelayNetworkLayer( + [ + customErrorMiddleware, + cacheMiddleware({ + size: 100, // max 100 requests + ttl: 900000, // 15 minutes + clearOnMutation: true, + }), + urlMiddleware({ + url: () => Promise.resolve(graphqlURL), + }), + batchMiddleware({ + batchUrl: (requestMap: any) => Promise.resolve(graphqlURL), + batchTimeout: 0, + allowMutations: true, + }), + retryMiddleware({ + fetchTimeout: 15000, + retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100, + // or simple array [3200, 6400, 12800, 25600, 51200, 102400, 204800, 409600], + statusCodes: [500, 503, 504], + }), + authMiddleware({ + token: tokenGetter, + }), + clientIDMiddleware(clientID), + ], + { subscribeFn: createSubscriptionFunction(subscriptionClient) } + ); } diff --git a/src/core/client/framework/lib/network/index.ts b/src/core/client/framework/lib/network/index.ts index ddc1b20e3..aa8c38332 100644 --- a/src/core/client/framework/lib/network/index.ts +++ b/src/core/client/framework/lib/network/index.ts @@ -1,3 +1,7 @@ export { default as createNetwork, TokenGetter } from "./createNetwork"; export { default as extractGraphQLError } from "./extractGraphQLError"; export { default as extractError } from "./extractError"; +export { + default as createManagedSubscriptionClient, + ManagedSubscriptionClient, +} from "./createManagedSubscriptionClient"; diff --git a/src/core/client/framework/lib/relay/index.ts b/src/core/client/framework/lib/relay/index.ts index eb03027c0..8b8af6df3 100644 --- a/src/core/client/framework/lib/relay/index.ts +++ b/src/core/client/framework/lib/relay/index.ts @@ -43,3 +43,11 @@ export { default as useRefetch } from "./useRefetch"; export { default as useLoadMore } from "./useLoadMore"; export { default as lookup } from "./lookup"; export { default as useLocal } from "./useLocal"; +export { + useSubscription, + createSubscription, + SubscriptionProp, + SubscriptionVariables, + withSubscription, + combineDisposables, +} from "./subscription"; diff --git a/src/core/client/framework/lib/relay/subscription.tsx b/src/core/client/framework/lib/relay/subscription.tsx new file mode 100644 index 000000000..140bc912b --- /dev/null +++ b/src/core/client/framework/lib/relay/subscription.tsx @@ -0,0 +1,98 @@ +import { useCallback } from "react"; +import { InferableComponentEnhancer } from "recompose"; +import { Disposable, Environment } from "relay-runtime"; + +import { CoralContext, useCoralContext } from "../bootstrap"; + +export type SubscriptionVariables< + T extends { variables: any } +> = T["variables"]; +export interface Subscription { + name: N; + subscribe: ( + environment: Environment, + variables: V, + context: CoralContext + ) => Disposable; +} + +export type SubscriptionProp< + T extends Subscription +> = T extends Subscription + ? Parameters[1] extends undefined + ? () => Disposable + : keyof Parameters[1] extends never + ? () => Disposable + : (variables: V) => Disposable + : never; + +export function createSubscription( + name: N, + subscribe: ( + environment: Environment, + variables: V, + context: CoralContext + ) => Disposable +): Subscription { + return { + name, + subscribe, + }; +} + +/** + * useSubscription is a React Hook that + * returns a callback to subscribes to a Subscription. + */ +export function useSubscription( + subscription: Subscription +): SubscriptionProp { + const context = useCoralContext(); + return useCallback>( + ((variables: V) => { + context.eventEmitter.emit(`subscription.${subscription.name}`, variables); + return subscription.subscribe( + context.relayEnvironment, + variables, + context + ); + }) as any, + [context] + ); +} + +/** + * withSubscription creates a HOC that injects the subscription as + * a property. + * + * @deprecated use `useFetch` instead + */ +export function withSubscription( + subscription: Subscription +): InferableComponentEnhancer< + { [P in N]: SubscriptionProp } +> { + return (BaseComponent: React.ComponentType) => { + { + const sub = useSubscription(subscription); + return props => { + const finalProps = { + ...props, + [subscription.name]: sub, + }; + return ; + }; + } + }; +} + +/** + * Combines disposables into one. + */ +export function combineDisposables(...disposables: Disposable[]): Disposable { + return { + dispose: () => { + disposables.forEach(d => d.dispose()); + }, + }; +} diff --git a/src/core/client/framework/lib/rest.ts b/src/core/client/framework/lib/rest.ts index 39e18c245..4bf21af6e 100644 --- a/src/core/client/framework/lib/rest.ts +++ b/src/core/client/framework/lib/rest.ts @@ -1,6 +1,8 @@ -import { Overwrite } from "coral-framework/types"; import { merge } from "lodash"; +import { CLIENT_ID_HEADER } from "coral-common/constants"; +import { Overwrite } from "coral-framework/types"; + import { extractError } from "./network"; const buildOptions = (inputOptions: RequestInit = {}) => { @@ -52,10 +54,12 @@ type PartialRequestInit = Overwrite, { body?: any }> & { export class RestClient { public readonly uri: string; private tokenGetter?: () => string; + private clientID?: string; - constructor(uri: string, tokenGetter?: () => string) { + constructor(uri: string, tokenGetter?: () => string, clientID?: string) { this.uri = uri; this.tokenGetter = tokenGetter; + this.clientID = clientID; } public async fetch( @@ -71,6 +75,13 @@ export class RestClient { }, }); } + if (this.clientID) { + opts = merge({}, opts, { + headers: { + [CLIENT_ID_HEADER]: this.clientID, + }, + }); + } const response = await fetch(`${this.uri}${path}`, buildOptions(opts)); return handleResp(response); } diff --git a/src/core/client/framework/schema/custom.ts b/src/core/client/framework/schema/custom.ts index d953b960f..4d3cc85d4 100644 --- a/src/core/client/framework/schema/custom.ts +++ b/src/core/client/framework/schema/custom.ts @@ -12,12 +12,16 @@ import { GQLCOMMENT_STATUS, GQLLOCALES, GQLMODERATION_MODE, + GQLMODERATION_QUEUE, GQLSTORY_STATUS, GQLUSER_AUTH_CONDITIONS, GQLUSER_ROLE, GQLUSER_STATUS, } from "./__generated__/types"; +export type GQLMODERATION_QUEUE_RL = RelayEnumLiteral< + typeof GQLMODERATION_QUEUE +>; export type GQLUSER_ROLE_RL = RelayEnumLiteral; export type GQLUSER_STATUS_RL = RelayEnumLiteral; export type GQLCOMMENT_FLAG_DETECTED_REASON_RL = RelayEnumLiteral< diff --git a/src/core/client/framework/testHelpers/createRelayEnvironment.ts b/src/core/client/framework/testHelpers/createRelayEnvironment.ts index 5b67e8142..8c5686aaa 100644 --- a/src/core/client/framework/testHelpers/createRelayEnvironment.ts +++ b/src/core/client/framework/testHelpers/createRelayEnvironment.ts @@ -1,6 +1,6 @@ -import { graphql, GraphQLSchema } from "graphql"; +import { graphql, GraphQLSchema, parse } from "graphql"; import { IResolvers } from "graphql-tools"; - +import { SubscribeFunction } from "react-relay-network-modern/es"; import { commitLocalUpdate, Environment, @@ -24,6 +24,8 @@ import { ModerationNudgeError, } from "coral-framework/lib/errors"; +import { SubscriptionHandler } from "./createSubscriptionHandler"; + export interface CreateRelayEnvironmentNetworkParams { /** project name of graphql-config */ projectName: string; @@ -33,6 +35,8 @@ export interface CreateRelayEnvironmentNetworkParams { logNetwork?: boolean; /** If enabled, graphql errors will be muted */ muteNetworkErrors?: boolean; + /** handler for subscriptions */ + subscriptionHandler?: SubscriptionHandler; } export interface CreateRelayEnvironmentParams { @@ -89,6 +93,55 @@ function createFetch({ }; } +function resolveArguments( + variables: Record = {}, + args: any[] = [] +) { + return args.reduce((res, a) => { + const argName = a.name.value; + const variableName = a.value.name.value; + if (variableName in variables) { + res[argName] = variables[variableName]; + } + return res; + }, {}); +} + +function createSubscribe( + subscriptionHandler: SubscriptionHandler +): SubscribeFunction { + const fn: SubscribeFunction = ( + operation, + variables, + cacheConfig, + observer + ) => { + // TODO: (cvle) This could probably made less brittle to changes in the order of the + // document AST. + const subscriptionSelections = (parse(operation.text!) as any) + .definitions[0].selectionSet.selections as any[]; + const sel = subscriptionSelections[0]; + const subscription = { + field: sel.name.value, + variables: resolveArguments(variables, sel.arguments), + dispatch: (response: any) => { + observer.onNext({ + data: { + [sel.name.value]: response, + }, + }); + }, + }; + subscriptionHandler.add(subscription); + return { + dispose: () => { + subscriptionHandler.remove(subscription); + }, + }; + }; + return fn; +} + /** * create Relay environment for tests environments. */ @@ -106,7 +159,10 @@ export default function createRelayEnvironment( wrapFetchWithLogger(createFetch({ schema }), { logResult: params.network.logNetwork, muteErrors: params.network.muteNetworkErrors, - }) + }), + params.network.subscriptionHandler + ? (createSubscribe(params.network.subscriptionHandler) as any) + : undefined ); } const environment = new Environment({ diff --git a/src/core/client/framework/testHelpers/createSubscriptionHandler.ts b/src/core/client/framework/testHelpers/createSubscriptionHandler.ts new file mode 100644 index 000000000..5ad4d9b30 --- /dev/null +++ b/src/core/client/framework/testHelpers/createSubscriptionHandler.ts @@ -0,0 +1,92 @@ +import { GQLSubscription } from "coral-framework/schema"; + +import { DeepPartial } from "coral-framework/types"; + +export type SubscriptionVariables< + T extends SubscriptionResolver +> = T extends SubscriptionResolver ? V : any; + +export type SubscriptionResponse< + T extends SubscriptionResolver +> = T extends SubscriptionResolver ? DeepPartial : any; + +/** + * SubscriptionResolver matches the shape of Subscription + * resolvers in the schema generated types. + */ +export interface SubscriptionResolver { + resolve?: (parent: any, args: V, context: any, info: any) => R; +} + +type SubscriptionField = keyof GQLSubscription; + +/** + * Subscription represents a subscription currently requested from the client. + */ +export interface Subscription = any> { + /** field of the subscription field being requested */ + field: SubscriptionField; + /** variables of the subscription field being requested */ + variables: SubscriptionVariables; + /** dispatch data to this subscription */ + dispatch(data: SubscriptionResponse): void; +} + +/** + * SubscriptionHandlerReadOnly enables to write tests with subscriptions. + */ +export interface SubscriptionHandlerReadOnly { + /** List of current active subscriptions */ + readonly subscriptions: ReadonlyArray; + /** + * dispatch will look for subscriptions of the field `field` and + * calls the `callback` for each of them. If `callback` returns data, + * it'll be dispatched to that subscription. + * @param field name of subscription field to look for. + * @param callback callback is called for every subscription on this field. + */ + dispatch = any>( + field: SubscriptionField, + callback: ( + variables: SubscriptionVariables + ) => SubscriptionResponse | void + ): void; + has(field: SubscriptionField): boolean; +} + +/** + * SubscriptionHandler enables to write tests with subscriptions + * and to manipulate the current list of subscriptions. + */ +export interface SubscriptionHandler extends SubscriptionHandlerReadOnly { + add(subscription: Subscription): void; + remove(subscription: Subscription): void; +} + +export default function createSubscriptionHandler(): SubscriptionHandler { + const subscriptions: Subscription[] = []; + const handler: SubscriptionHandler = { + subscriptions, + dispatch: (field, callback) => { + subscriptions.forEach(s => { + if (s.field === field) { + const data = callback(s.variables as any); + if (data) { + s.dispatch(data); + } + } + }); + }, + has: field => subscriptions.some(s => s.field === field), + add: s => { + subscriptions.push(s); + }, + remove: s => { + const index = subscriptions.findIndex(x => x === s); + if (index !== -1) { + subscriptions.splice(index, 1); + } + }, + }; + return handler; +} diff --git a/src/core/client/framework/testHelpers/createTestRenderer.tsx b/src/core/client/framework/testHelpers/createTestRenderer.tsx index 0afdbc9c6..9a29ddd2d 100644 --- a/src/core/client/framework/testHelpers/createTestRenderer.tsx +++ b/src/core/client/framework/testHelpers/createTestRenderer.tsx @@ -18,6 +18,9 @@ import { createUUIDGenerator } from "coral-framework/testHelpers"; import createFluentBundle from "./createFluentBundle"; import createRelayEnvironment from "./createRelayEnvironment"; +import createSubscriptionHandler, { + SubscriptionHandlerReadOnly, +} from "./createSubscriptionHandler"; export type Resolver = ( parent: any, @@ -66,6 +69,7 @@ export default function createTestRenderer< element: React.ReactNode, params: CreateTestRendererParams ) { + const subscriptionHandler = createSubscriptionHandler(); const environment = createRelayEnvironment({ network: { // Set this to true, to see graphql responses. @@ -73,6 +77,7 @@ export default function createTestRenderer< resolvers: params.resolvers as IResolvers, muteNetworkErrors: params.muteNetworkErrors, projectName: "tenant", + subscriptionHandler, }, initLocalState: (localRecord, source, env) => { if (params.initLocalState) { @@ -111,5 +116,9 @@ export default function createTestRenderer< { createNodeMock } ); }); - return { context, testRenderer: testRenderer! }; + return { + context, + testRenderer: testRenderer!, + subscriptionHandler: subscriptionHandler as SubscriptionHandlerReadOnly, + }; } diff --git a/src/core/client/framework/testHelpers/matchText.ts b/src/core/client/framework/testHelpers/matchText.ts index d46658da5..7a20f39ec 100644 --- a/src/core/client/framework/testHelpers/matchText.ts +++ b/src/core/client/framework/testHelpers/matchText.ts @@ -11,25 +11,21 @@ export default function matchText( text: string, options: TextMatchOptions = {} ) { - if (typeof pattern === "string") { - let a = text; - let b = pattern; - if (options.trim || options.trim === undefined) { - a = a.trim(); - b = b.trim(); - } - if ( - options.collapseWhitespace || - options.collapseWhitespace === undefined - ) { - a = a.replace(/\s+/g, " "); - b = b.replace(/\s+/g, " "); - } - + let a = pattern; + let b = text; + if (options.trim || options.trim === undefined) { + a = typeof a === "string" ? a.trim() : a; + b = b.trim(); + } + if (options.collapseWhitespace || options.collapseWhitespace === undefined) { + a = typeof a === "string" ? a.replace(/\s+/g, " ") : a; + b = b.replace(/\s+/g, " "); + } + if (typeof a === "string") { if (options.exact || options.exact === undefined) { return a === b; } - return text.toLowerCase().includes(pattern.toLowerCase()); + return b.toLowerCase().includes(a.toLowerCase()); } - return pattern.test(text); + return a.test(b); } diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts new file mode 100644 index 000000000..31f829e06 --- /dev/null +++ b/src/core/common/constants.ts @@ -0,0 +1,18 @@ +/** + * CLIENT_ID_HEADER references the name of the header used to extract/send the + * client ID to enable automatic de-duplication. + */ +export const CLIENT_ID_HEADER = "X-Coral-Client-ID"; + +/** + * CLIENT_ID_PARAM references the name of the param used to send the client ID + * via connectionParams when authenticating a websocket connection to enable + * automatic de-duplication. + */ +export const CLIENT_ID_PARAM = "clientID"; + +/** + * ACCESS_TOKEN_PARAM references the name of the param used to send the access + * token in connectionParams when authenticating a websocket connection. + */ +export const ACCESS_TOKEN_PARAM = "accessToken"; diff --git a/src/core/server/app/handlers/api/account/confirm.ts b/src/core/server/app/handlers/api/account/confirm.ts index 9f3cd9598..f7caeacf5 100644 --- a/src/core/server/app/handlers/api/account/confirm.ts +++ b/src/core/server/app/handlers/api/account/confirm.ts @@ -10,7 +10,7 @@ import { } from "coral-server/errors"; import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; import { retrieveUser, User } from "coral-server/models/user"; -import { decodeJWT, extractJWTFromRequest } from "coral-server/services/jwt"; +import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; import { confirmEmail, sendConfirmationEmail, @@ -165,7 +165,7 @@ export const confirmCheckHandler = ({ // TODO: evaluate verifying if the Tenant allows verifications to short circuit. // Grab the token from the request. - const tokenString = extractJWTFromRequest(req, true); + const tokenString = extractTokenFromRequest(req, true); if (!tokenString) { return res.sendStatus(400); } @@ -225,7 +225,7 @@ export const confirmHandler = ({ const tenant = coral.tenant!; // Grab the token from the request. - const tokenString = extractJWTFromRequest(req, true); + const tokenString = extractTokenFromRequest(req, true); if (!tokenString) { return res.sendStatus(400); } diff --git a/src/core/server/app/handlers/api/auth/local/forgot.ts b/src/core/server/app/handlers/api/auth/local/forgot.ts index 384c2f886..5c4fc257b 100644 --- a/src/core/server/app/handlers/api/auth/local/forgot.ts +++ b/src/core/server/app/handlers/api/auth/local/forgot.ts @@ -5,7 +5,7 @@ import { validate } from "coral-server/app/request/body"; import { RequestLimiter } from "coral-server/app/request/limiter"; import { IntegrationDisabled } from "coral-server/errors"; import { retrieveUserWithProfile } from "coral-server/models/user"; -import { decodeJWT, extractJWTFromRequest } from "coral-server/services/jwt"; +import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; import { generateResetURL, resetPassword, @@ -182,7 +182,7 @@ export const forgotResetHandler = ({ ); // Grab the token from the request. - const tokenString = extractJWTFromRequest(req, true); + const tokenString = extractTokenFromRequest(req, true); if (!tokenString) { return res.sendStatus(400); } @@ -248,7 +248,7 @@ export const forgotCheckHandler = ({ } // Grab the token from the request. - const tokenString = extractJWTFromRequest(req, true); + const tokenString = extractTokenFromRequest(req, true); if (!tokenString) { return res.sendStatus(400); } diff --git a/src/core/server/app/handlers/api/graphql.ts b/src/core/server/app/handlers/api/graphql.ts index 00ff16674..3a61d7b41 100644 --- a/src/core/server/app/handlers/api/graphql.ts +++ b/src/core/server/app/handlers/api/graphql.ts @@ -1,48 +1,56 @@ +import { CLIENT_ID_HEADER } from "coral-common/constants"; import { AppOptions } from "coral-server/app"; import { graphqlBatchMiddleware, graphqlMiddleware, } from "coral-server/app/middleware/graphql"; -import TenantContext from "coral-server/graph/tenant/context"; +import TenantContext, { + TenantContextOptions, +} from "coral-server/graph/tenant/context"; import { Request, RequestHandler } from "coral-server/types/express"; export type GraphMiddlewareOptions = Pick< AppOptions, - | "schema" | "config" + | "i18n" + | "mailerQueue" | "mongo" | "redis" - | "mailerQueue" + | "schema" | "scraperQueue" | "signingConfig" - | "i18n" + | "pubsub" + | "tenantCache" + | "metrics" >; export const graphQLHandler = ({ schema, config, + metrics, ...options }: GraphMiddlewareOptions): RequestHandler => graphqlBatchMiddleware( - graphqlMiddleware(config, async (req: Request) => { - if (!req.coral) { - throw new Error("coral was not set"); - } + graphqlMiddleware( + config, + async (req: Request) => { + if (!req.coral) { + throw new Error("coral was not set"); + } - // Pull out some useful properties from Coral. - const { id, now, tenant, cache, logger } = req.coral; + // Pull out some useful properties from Coral. + const { id, now, tenant, cache, logger } = req.coral; - if (!cache) { - throw new Error("cache was not set"); - } + if (!cache) { + throw new Error("cache was not set"); + } - if (!tenant) { - throw new Error("tenant was not set"); - } + if (!tenant) { + throw new Error("tenant was not set"); + } - return { - schema, - context: new TenantContext({ + // Create some new options to store the tenant context details inside. + const opts: TenantContextOptions = { ...options, id, now, @@ -50,9 +58,25 @@ export const graphQLHandler = ({ config, tenant, logger, - user: req.user, - tenantCache: cache.tenant, - }), - }; - }) + }; + + // Add the user if there is one. + if (req.user) { + opts.user = req.user; + } + + // Add the clientID if there is one on the request. + const clientID = req.get(CLIENT_ID_HEADER); + if (clientID) { + // TODO: (wyattjoh) validate length + opts.clientID = clientID; + } + + return { + schema, + context: new TenantContext(opts), + }; + }, + metrics + ) ); diff --git a/src/core/server/app/helpers/hostname.ts b/src/core/server/app/helpers/hostname.ts new file mode 100644 index 000000000..6424142e1 --- /dev/null +++ b/src/core/server/app/helpers/hostname.ts @@ -0,0 +1,21 @@ +import { IncomingMessage } from "http"; + +/** + * Duplicates the functionality from expressjs: + * + * https://github.com/expressjs/express/blob/b8e50568af9c73ef1ade434e92c60d389868361d/lib/request.js#L416-L450 + * + * @param req incoming request + */ +export function getHostname(req: IncomingMessage) { + const host = req.headers["x-forwarded-host"] || req.headers.host; + if (!host || Array.isArray(host)) { + return null; + } + + // IPv6 literal support + const offset = host[0] === "[" ? host.indexOf("]") + 1 : 0; + const index = host.indexOf(":", offset); + + return index !== -1 ? host.substring(0, index) : host; +} diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index d0d3cfe65..90c0c366d 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -2,6 +2,7 @@ import cons from "consolidate"; import cors from "cors"; import { Express } from "express"; import { GraphQLSchema } from "graphql"; +import { RedisPubSub } from "graphql-redis-subscriptions"; import http from "http"; import { Db } from "mongodb"; import nunjucks from "nunjucks"; @@ -16,6 +17,7 @@ import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { ScraperQueue } from "coral-server/queue/tasks/scraper"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; +import { Metrics } from "coral-server/services/metrics"; import { AugmentedRedis } from "coral-server/services/redis"; import TenantCache from "coral-server/services/tenant/cache"; @@ -35,8 +37,9 @@ export interface AppOptions { schema: GraphQLSchema; signingConfig: JWTSigningConfig; tenantCache: TenantCache; - metrics: boolean; disableClientRoutes: boolean; + metrics?: Metrics; + pubsub: RedisPubSub; } /** @@ -52,8 +55,10 @@ export async function createApp(options: AppOptions): Promise { // Logging parent.use(accessLogger); - // Capturing metrics. - parent.use(metricsRecorder()); + if (options.metrics) { + // Capturing metrics. + parent.use(metricsRecorder(options.metrics)); + } // Create some services for the router. const passport = createPassport(options); diff --git a/src/core/server/app/middleware/graphql/index.ts b/src/core/server/app/middleware/graphql/index.ts index 652748812..7ccc61671 100644 --- a/src/core/server/app/middleware/graphql/index.ts +++ b/src/core/server/app/middleware/graphql/index.ts @@ -1,7 +1,6 @@ -import { GraphQLOptions } from "apollo-server-express"; +import { GraphQLExtension, GraphQLOptions } from "apollo-server-express"; import { Handler } from "express"; import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql"; -import { Counter, Histogram } from "prom-client"; // TODO: when https://github.com/apollographql/apollo-server/pull/1907 is merged, update this import path import { @@ -16,6 +15,7 @@ import { LoggerExtension, MetricsExtension, } from "coral-server/graph/common/extensions"; +import { Metrics } from "coral-server/services/metrics"; export * from "./batch"; @@ -42,37 +42,28 @@ const NoIntrospection = (context: ValidationContext) => ({ */ export const graphqlMiddleware = ( config: Config, - requestOptions: ExpressGraphQLOptionsFunction + requestOptions: ExpressGraphQLOptionsFunction, + metrics?: Metrics ): Handler => { - // Configure the metrics handlers. - const executedGraphQueriesTotalCounter = new Counter({ - name: "coral_executed_graph_queries_total", - help: "number of GraphQL queries executed", - labelNames: ["operation_type", "operation_name"], - }); + const extensions: Array<() => GraphQLExtension> = [ + () => new ErrorWrappingExtension(), + () => new LoggerExtension(), + ]; - const graphQLExecutionTimingsHistogram = new Histogram({ - name: "coral_executed_graph_queries_timings", - help: "timings for execution times of GraphQL operations", - buckets: [0.1, 5, 15, 50, 100, 500], - labelNames: ["operation_type", "operation_name"], - }); + // Add the metrics extension if provided. + if (metrics) { + extensions.push( + () => + // Pass the metrics to the extension so it can increment. + new MetricsExtension(metrics) + ); + } // Create a new baseOptions that will be merged into the new options. const baseOptions: Omit = { // Disable the debug mode, as we already add in our logging function. debug: false, - // Include extensions. - extensions: [ - () => new ErrorWrappingExtension(), - () => new LoggerExtension(), - () => - // Pass the metrics to the extension so it can increment. - new MetricsExtension({ - executedGraphQueriesTotalCounter, - graphQLExecutionTimingsHistogram, - }), - ], + extensions, }; if (config.get("env") === "production" && !config.get("enable_graphiql")) { diff --git a/src/core/server/app/middleware/metrics.ts b/src/core/server/app/middleware/metrics.ts index c2fc491ce..a5546f92a 100644 --- a/src/core/server/app/middleware/metrics.ts +++ b/src/core/server/app/middleware/metrics.ts @@ -1,22 +1,15 @@ -import { RequestHandler } from "express"; import onFinished from "on-finished"; import now from "performance-now"; -import { Counter, Histogram } from "prom-client"; -export const metricsRecorder = (): RequestHandler => { - const httpRequestsTotal = new Counter({ - name: "http_requests_total", - help: "Total number of HTTP requests made.", - labelNames: ["code", "method"], - }); +import { Metrics } from "coral-server/services/metrics"; +import { RequestHandler } from "coral-server/types/express"; - const httpRequestDurationMilliseconds = new Histogram({ - name: "http_request_duration_milliseconds", - help: "Histogram of latencies for HTTP requests.", - buckets: [0.1, 5, 15, 50, 100, 500], - labelNames: ["method", "handler"], - }); +export type MetricsRecorderOptions = Metrics; +export const metricsRecorder = ({ + httpRequestsTotal, + httpRequestDurationMilliseconds, +}: Metrics): RequestHandler => { return (req, res, next) => { const startTime = now(); diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 682db782f..ff97ac044 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -14,7 +14,7 @@ import { validate } from "coral-server/app/request/body"; import { AuthenticationError } from "coral-server/errors"; import { User } from "coral-server/models/user"; import { - extractJWTFromRequest, + extractTokenFromRequest, JWTSigningConfig, revokeJWT, signTokenString, @@ -68,7 +68,7 @@ const LogoutTokenSchema = Joi.object().keys({ export async function handleLogout(redis: Redis, req: Request, res: Response) { // Extract the token from the request. - const token = extractJWTFromRequest(req); + const token = extractTokenFromRequest(req); if (!token) { // TODO: (wyattjoh) return a better error. throw new Error("logout requires a token on the request, none was found"); diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts index 664ada873..be289f08d 100644 --- a/src/core/server/app/middleware/passport/strategies/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -5,7 +5,7 @@ import { AppOptions } from "coral-server/app"; import { TenantNotFoundError, TokenInvalidError } from "coral-server/errors"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; -import { extractJWTFromRequest } from "coral-server/services/jwt"; +import { extractTokenFromRequest } from "coral-server/services/jwt"; import { Request } from "coral-server/types/express"; import { JWTToken, JWTVerifier } from "./verifiers/jwt"; @@ -20,7 +20,7 @@ export type JWTStrategyOptions = Pick< /** * Token is the various forms of the Token that can be verified. */ -type Token = OIDCIDToken | SSOToken | JWTToken | object | string | null; +export type Token = OIDCIDToken | SSOToken | JWTToken | object | string | null; /** * Verifier allows different implementations to offer ways to verify a given @@ -44,6 +44,41 @@ export interface Verifier { supports: (token: T | object, tenant: Tenant) => token is T; } +export function createVerifiers( + options: JWTStrategyOptions +): Array> { + return [ + new OIDCVerifier(options), + new SSOVerifier(options), + new JWTVerifier(options), + ]; +} + +export function verifyAndRetrieveUser( + verifiers: Array>, + tenant: Tenant, + tokenString: string, + now = new Date() +) { + const token: Token = jwt.decode(tokenString); + if (!token || typeof token === "string") { + throw new TokenInvalidError(tokenString, "token could not be decoded"); + } + + // Try to verify the token. + for (const verifier of verifiers) { + if (verifier.supports(token, tenant)) { + return verifier.verify(tokenString, token, tenant, now); + } + } + + // No verifier could be found. + throw new TokenInvalidError( + tokenString, + "no suitable jwt verifier could be found" + ); +} + export class JWTStrategy extends Strategy { public name = "jwt"; @@ -52,36 +87,12 @@ export class JWTStrategy extends Strategy { constructor(options: JWTStrategyOptions) { super(); - this.verifiers = [ - new OIDCVerifier(options), - new SSOVerifier(options), - new JWTVerifier(options), - ]; - } - - private async verify(tokenString: string, tenant: Tenant, now = new Date()) { - const token: Token = jwt.decode(tokenString); - if (!token || typeof token === "string") { - throw new TokenInvalidError(tokenString, "token could not be decoded"); - } - - // Try to verify the token. - for (const verifier of this.verifiers) { - if (verifier.supports(token, tenant)) { - return verifier.verify(tokenString, token, tenant, now); - } - } - - // No verifier could be found. - throw new TokenInvalidError( - tokenString, - "no suitable jwt verifier could be found" - ); + this.verifiers = createVerifiers(options); } public async authenticate(req: Request) { // Get the token from the request. - const token = extractJWTFromRequest(req); + const token = extractTokenFromRequest(req); if (!token) { // There was no token on the request, so don't bother actually checking // anything further. @@ -94,7 +105,12 @@ export class JWTStrategy extends Strategy { } try { - const user = await this.verify(token, tenant, now); + const user = await verifyAndRetrieveUser( + this.verifiers, + tenant, + token, + now + ); if (!user) { return this.pass(); } diff --git a/src/core/server/app/router/index.ts b/src/core/server/app/router/index.ts index 647aae072..7f114b74e 100644 --- a/src/core/server/app/router/index.ts +++ b/src/core/server/app/router/index.ts @@ -69,5 +69,11 @@ function attachGraphiQL(router: Router, app: AppOptions) { } // GraphiQL - router.get("/graphiql", playground({ endpoint: "/api/graphql" })); + router.get( + "/graphiql", + playground({ + endpoint: "/api/graphql", + subscriptionEndpoint: "/api/graphql/live", + }) + ); } diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index a451f01fd..c13cd0016 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -406,10 +406,10 @@ export class StoryNotFoundError extends CoralError { } export class CommentNotFoundError extends CoralError { - constructor(commentID: string) { + constructor(commentID: string, commentRevisionID?: string) { super({ code: ERROR_CODES.COMMENT_NOT_FOUND, - context: { pvt: { commentID } }, + context: { pvt: { commentID, commentRevisionID } }, }); } } diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts index 2a6ec7695..b408f7018 100644 --- a/src/core/server/graph/common/context.ts +++ b/src/core/server/graph/common/context.ts @@ -1,3 +1,4 @@ +import { Db } from "mongodb"; import uuid from "uuid"; import { LanguageCode } from "coral-common/helpers/i18n/locales"; @@ -5,7 +6,9 @@ import { Config } from "coral-server/config"; import logger, { Logger } from "coral-server/logger"; import { User } from "coral-server/models/user"; import { I18n } from "coral-server/services/i18n"; +import { AugmentedRedis } from "coral-server/services/redis"; import { Request } from "coral-server/types/express"; +import { RedisPubSub } from "graphql-redis-subscriptions"; export interface CommonContextOptions { id?: string; @@ -14,8 +17,12 @@ export interface CommonContextOptions { req?: Request; logger?: Logger; lang?: LanguageCode; + disableCaching?: boolean; config: Config; i18n: I18n; + pubsub: RedisPubSub; + mongo: Db; + redis: AugmentedRedis; } export default class CommonContext { @@ -27,6 +34,10 @@ export default class CommonContext { public readonly lang: LanguageCode; public readonly now: Date; public readonly logger: Logger; + public readonly pubsub: RedisPubSub; + public readonly mongo: Db; + public readonly redis: AugmentedRedis; + public readonly disableCaching: boolean; constructor({ id = uuid.v1(), @@ -37,6 +48,10 @@ export default class CommonContext { config, i18n, lang = i18n.getDefaultLang(), + pubsub, + mongo, + redis, + disableCaching = false, }: CommonContextOptions) { this.id = id; this.logger = log.child({ @@ -49,5 +64,9 @@ export default class CommonContext { this.config = config; this.i18n = i18n; this.lang = lang; + this.pubsub = pubsub; + this.mongo = mongo; + this.redis = redis; + this.disableCaching = disableCaching; } } diff --git a/src/core/server/graph/common/extensions/LoggerExtension.ts b/src/core/server/graph/common/extensions/LoggerExtension.ts index 8191cfbe7..5889fad23 100644 --- a/src/core/server/graph/common/extensions/LoggerExtension.ts +++ b/src/core/server/graph/common/extensions/LoggerExtension.ts @@ -1,4 +1,4 @@ -import { ExecutionArgs, GraphQLError } from "graphql"; +import { DocumentNode, ExecutionArgs, GraphQLError } from "graphql"; import { EndHandler, GraphQLExtension, @@ -13,6 +13,20 @@ export function logError(ctx: CommonContext, err: GraphQLError) { ctx.logger.error({ err }, "graphql query error"); } +export function logQuery( + ctx: CommonContext, + document: DocumentNode, + responseTime?: number +) { + ctx.logger.debug( + { + responseTime, + ...getOperationMetadata(document), + }, + "graphql query" + ); +} + export class LoggerExtension implements GraphQLExtension { public executionDidStart(o: { executionArgs: ExecutionArgs; @@ -27,12 +41,10 @@ export class LoggerExtension implements GraphQLExtension { const responseTime = Math.round(now() - startTime); // Log out the details of the request. - o.executionArgs.contextValue.logger.debug( - { - responseTime, - ...getOperationMetadata(o.executionArgs.document), - }, - "graphql query" + logQuery( + o.executionArgs.contextValue, + o.executionArgs.document, + responseTime ); }; } diff --git a/src/core/server/graph/common/extensions/MetricsExtension.ts b/src/core/server/graph/common/extensions/MetricsExtension.ts index fe825ad6c..f73140f6c 100644 --- a/src/core/server/graph/common/extensions/MetricsExtension.ts +++ b/src/core/server/graph/common/extensions/MetricsExtension.ts @@ -1,22 +1,13 @@ +import CommonContext from "coral-server/graph/common/context"; +import { Metrics } from "coral-server/services/metrics"; import { ExecutionArgs } from "graphql"; import { EndHandler, GraphQLExtension } from "graphql-extensions"; import now from "performance-now"; -import { Counter, Histogram } from "prom-client"; -import CommonContext from "coral-server/graph/common/context"; import { getOperationMetadata } from "./helpers"; -export interface MetricsExtensionOptions { - executedGraphQueriesTotalCounter: Counter; - graphQLExecutionTimingsHistogram: Histogram; -} - export class MetricsExtension implements GraphQLExtension { - private options: MetricsExtensionOptions; - - constructor(options: MetricsExtensionOptions) { - this.options = options; - } + constructor(private metrics: Metrics) {} public executionDidStart(o: { executionArgs: ExecutionArgs; @@ -37,11 +28,11 @@ export class MetricsExtension implements GraphQLExtension { if (operation && operationName) { // Increment the graph query value, tagging with the name of the query. - this.options.executedGraphQueriesTotalCounter + this.metrics.executedGraphQueriesTotalCounter .labels(operation, operationName) .inc(); - this.options.graphQLExecutionTimingsHistogram + this.metrics.graphQLExecutionTimingsHistogram .labels(operation, operationName) .observe(responseTime); } diff --git a/src/core/server/graph/common/subscriptions/pubsub.ts b/src/core/server/graph/common/subscriptions/pubsub.ts new file mode 100644 index 000000000..a4f79bf5a --- /dev/null +++ b/src/core/server/graph/common/subscriptions/pubsub.ts @@ -0,0 +1,12 @@ +import { RedisPubSub } from "graphql-redis-subscriptions"; +import { Redis } from "ioredis"; + +export function createPubSubClient( + publisher: Redis, + subscriber: Redis +): RedisPubSub { + return new RedisPubSub({ + publisher, + subscriber, + }); +} diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 74312f0cf..1c2a60f1d 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -1,52 +1,56 @@ -import { Db } from "mongodb"; - import CommonContext, { CommonContextOptions, } from "coral-server/graph/common/context"; +import { + createPublisher, + Publisher, +} from "coral-server/graph/tenant/subscriptions/publisher"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { ScraperQueue } from "coral-server/queue/tasks/scraper"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import { AugmentedRedis } from "coral-server/services/redis"; import TenantCache from "coral-server/services/tenant/cache"; import loaders from "./loaders"; import mutators from "./mutators"; export interface TenantContextOptions extends CommonContextOptions { - mongo: Db; - redis: AugmentedRedis; tenant: Tenant; tenantCache: TenantCache; mailerQueue: MailerQueue; scraperQueue: ScraperQueue; signingConfig?: JWTSigningConfig; + clientID?: string; } export default class TenantContext extends CommonContext { public readonly tenant: Tenant; public readonly tenantCache: TenantCache; - public readonly mongo: Db; - public readonly redis: AugmentedRedis; + public readonly mailerQueue: MailerQueue; public readonly scraperQueue: ScraperQueue; - public readonly loaders: ReturnType; - public readonly mutators: ReturnType; + public readonly publisher: Publisher; public readonly user?: User; public readonly signingConfig?: JWTSigningConfig; + public readonly clientID?: string; + public readonly loaders: ReturnType; + public readonly mutators: ReturnType; constructor(options: TenantContextOptions) { super({ ...options, lang: options.tenant.locale }); this.tenant = options.tenant; this.tenantCache = options.tenantCache; - this.user = options.user; - this.mongo = options.mongo; - this.redis = options.redis; this.scraperQueue = options.scraperQueue; this.mailerQueue = options.mailerQueue; this.signingConfig = options.signingConfig; + this.clientID = options.clientID; + this.publisher = createPublisher( + this.pubsub, + this.tenant.id, + this.clientID + ); this.loaders = loaders(this); this.mutators = mutators(this); } diff --git a/src/core/server/graph/tenant/loaders/Auth.ts b/src/core/server/graph/tenant/loaders/Auth.ts index 2d35ffd7f..ddfe4922a 100644 --- a/src/core/server/graph/tenant/loaders/Auth.ts +++ b/src/core/server/graph/tenant/loaders/Auth.ts @@ -8,7 +8,13 @@ export default (ctx: TenantContext) => ({ discoverOIDCConfiguration: new DataLoader< string, GQLDiscoveredOIDCConfiguration | null - >(issuers => - Promise.all(issuers.map(issuer => discoverOIDCConfiguration(issuer))) + >( + issuers => + Promise.all(issuers.map(issuer => discoverOIDCConfiguration(issuer))), + { + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cache: !ctx.disableCaching, + } ), }); diff --git a/src/core/server/graph/tenant/loaders/Comments.ts b/src/core/server/graph/tenant/loaders/Comments.ts index 08c1c7b4c..4a9cebf8c 100644 --- a/src/core/server/graph/tenant/loaders/Comments.ts +++ b/src/core/server/graph/tenant/loaders/Comments.ts @@ -50,10 +50,12 @@ const tagFilter = (tag?: GQLTAG): CommentConnectionInput["filter"] => { const primeCommentsFromConnection = (ctx: Context) => ( connection: Readonly>> ) => { - // For each of the nodes, prime the comment loader. - connection.nodes.forEach(comment => { - ctx.loaders.Comments.comment.prime(comment.id, comment); - }); + if (!ctx.disableCaching) { + // For each of the nodes, prime the comment loader. + connection.nodes.forEach(comment => { + ctx.loaders.Comments.comment.prime(comment.id, comment); + }); + } return connection; }; @@ -96,10 +98,16 @@ const mapVisibleComments = (user?: Pick) => ( ): Array | null> => comments.map(mapVisibleComment(user)); export default (ctx: Context) => ({ - comment: new DataLoader((ids: string[]) => - retrieveManyComments(ctx.mongo, ctx.tenant.id, ids).then( - mapVisibleComments(ctx.user) - ) + comment: new DataLoader( + (ids: string[]) => + retrieveManyComments(ctx.mongo, ctx.tenant.id, ids).then( + mapVisibleComments(ctx.user) + ), + { + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cache: !ctx.disableCaching, + } ), forFilter: ({ first = 10, @@ -217,13 +225,19 @@ export default (ctx: Context) => ({ // The cursor passed here is always going to be a number. before: before as number, }).then(primeCommentsFromConnection(ctx)), - sharedModerationQueueQueuesCounts: new SingletonResolver(() => - retrieveSharedModerationQueueQueuesCounts( - ctx.mongo, - ctx.redis, - ctx.tenant.id, - ctx.now - ) + sharedModerationQueueQueuesCounts: new SingletonResolver( + () => + retrieveSharedModerationQueueQueuesCounts( + ctx.mongo, + ctx.redis, + ctx.tenant.id, + ctx.now + ), + { + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cacheable: !ctx.disableCaching, + } ), tagCounts: new DataLoader((storyIDs: string[]) => retrieveStoryCommentTagCounts(ctx.mongo, ctx.tenant.id, storyIDs) diff --git a/src/core/server/graph/tenant/loaders/Stories.ts b/src/core/server/graph/tenant/loaders/Stories.ts index a227b546e..2825eef55 100644 --- a/src/core/server/graph/tenant/loaders/Stories.ts +++ b/src/core/server/graph/tenant/loaders/Stories.ts @@ -56,10 +56,12 @@ const queryFilter = (query?: string): StoryConnectionInput["filter"] => { const primeStoriesFromConnection = (ctx: TenantContext) => ( connection: Readonly>> ) => { - // For each of these nodes, prime the story loader. - connection.nodes.forEach(story => { - ctx.loaders.Stories.story.prime(story.id, story); - }); + if (!ctx.disableCaching) { + // For each of these nodes, prime the story loader. + connection.nodes.forEach(story => { + ctx.loaders.Stories.story.prime(story.id, story); + }); + } return connection; }; @@ -72,6 +74,9 @@ export default (ctx: TenantContext) => ({ { // TODO: (wyattjoh) see if there's something we can do to improve the cache key cacheKeyFn: (input: FindOrCreateStory) => `${input.id}:${input.url}`, + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cache: !ctx.disableCaching, } ), find: new DataLoader( @@ -81,10 +86,18 @@ export default (ctx: TenantContext) => ({ { // TODO: (wyattjoh) see if there's something we can do to improve the cache key cacheKeyFn: (input: FindStory) => `${input.id}:${input.url}`, + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cache: !ctx.disableCaching, } ), - story: new DataLoader(ids => - retrieveManyStories(ctx.mongo, ctx.tenant.id, ids) + story: new DataLoader( + ids => retrieveManyStories(ctx.mongo, ctx.tenant.id, ids), + { + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cache: !ctx.disableCaching, + } ), connection: ({ first = 10, after, status, query }: QueryToStoriesArgs) => retrieveStoryConnection(ctx.mongo, ctx.tenant.id, { @@ -99,6 +112,11 @@ export default (ctx: TenantContext) => ({ }, }).then(primeStoriesFromConnection(ctx)), debugScrapeMetadata: new DataLoader( - createManyBatchLoadFn((url: string) => scraper.scrape(url)) + createManyBatchLoadFn((url: string) => scraper.scrape(url)), + { + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cache: !ctx.disableCaching, + } ), }); diff --git a/src/core/server/graph/tenant/loaders/Users.ts b/src/core/server/graph/tenant/loaders/Users.ts index d4460b74a..c9418faa5 100644 --- a/src/core/server/graph/tenant/loaders/Users.ts +++ b/src/core/server/graph/tenant/loaders/Users.ts @@ -82,20 +82,27 @@ const statusFilter = ( const primeUsersFromConnection = (ctx: Context) => ( connection: Readonly>> ) => { - // For each of the nodes, prime the user loader. - connection.nodes.forEach(user => { - ctx.loaders.Users.user.prime(user.id, user); - }); + if (!ctx.disableCaching) { + // For each of the nodes, prime the user loader. + connection.nodes.forEach(user => { + ctx.loaders.Users.user.prime(user.id, user); + }); + } return connection; }; export default (ctx: Context) => { - const user = new DataLoader(ids => - retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids) + const user = new DataLoader( + ids => retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids), + { + // Disable caching for the DataLoader if the Context is designed to be + // long lived. + cache: !ctx.disableCaching, + } ); - if (ctx.user) { + if (ctx.user && !ctx.disableCaching) { // Prime the current logged in user in the dataloader cache. user.prime(ctx.user.id, ctx.user); } diff --git a/src/core/server/graph/tenant/loaders/util.ts b/src/core/server/graph/tenant/loaders/util.ts index 8a51683ac..79c9e9bb0 100644 --- a/src/core/server/graph/tenant/loaders/util.ts +++ b/src/core/server/graph/tenant/loaders/util.ts @@ -1,15 +1,28 @@ +export interface SingletonResolverOptions { + cacheable?: boolean; +} + /** * SingletonResolver is a cached loader for a single result. */ export class SingletonResolver { private cache: Promise | null = null; private resolver: () => Promise; + private cacheable: boolean; - constructor(resolver: () => Promise) { + constructor( + resolver: () => Promise, + { cacheable = true }: SingletonResolverOptions = {} + ) { this.resolver = resolver; + this.cacheable = cacheable; } public load() { + if (!this.cacheable) { + return this.resolver(); + } + if (this.cache) { return this.cache; } diff --git a/src/core/server/graph/tenant/mutators/Actions.ts b/src/core/server/graph/tenant/mutators/Actions.ts index c68e1e7ca..a0570fe3c 100644 --- a/src/core/server/graph/tenant/mutators/Actions.ts +++ b/src/core/server/graph/tenant/mutators/Actions.ts @@ -7,13 +7,13 @@ import { export const Actions = (ctx: TenantContext) => ({ approveComment: (input: GQLApproveCommentInput) => - approve(ctx.mongo, ctx.redis, ctx.tenant, { + approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { commentID: input.commentID, commentRevisionID: input.commentRevisionID, moderatorID: ctx.user!.id, }), rejectComment: (input: GQLRejectCommentInput) => - reject(ctx.mongo, ctx.redis, ctx.tenant, { + reject(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { commentID: input.commentID, commentRevisionID: input.commentRevisionID, moderatorID: ctx.user!.id, diff --git a/src/core/server/graph/tenant/mutators/Comments.ts b/src/core/server/graph/tenant/mutators/Comments.ts index 8b0d609b9..7221c115e 100644 --- a/src/core/server/graph/tenant/mutators/Comments.ts +++ b/src/core/server/graph/tenant/mutators/Comments.ts @@ -43,6 +43,7 @@ export const Comments = (ctx: TenantContext) => ({ create( ctx.mongo, ctx.redis, + ctx.publisher, ctx.tenant, ctx.user!, { authorID: ctx.user!.id, ...comment }, @@ -64,6 +65,7 @@ export const Comments = (ctx: TenantContext) => ({ edit( ctx.mongo, ctx.redis, + ctx.publisher, ctx.tenant, ctx.user!, { @@ -87,6 +89,7 @@ export const Comments = (ctx: TenantContext) => ({ createReaction( ctx.mongo, ctx.redis, + ctx.publisher, ctx.tenant, ctx.user!, { @@ -107,6 +110,7 @@ export const Comments = (ctx: TenantContext) => ({ createDontAgree( ctx.mongo, ctx.redis, + ctx.publisher, ctx.tenant, ctx.user!, { @@ -133,6 +137,7 @@ export const Comments = (ctx: TenantContext) => ({ createFlag( ctx.mongo, ctx.redis, + ctx.publisher, ctx.tenant, ctx.user!, { @@ -161,7 +166,7 @@ export const Comments = (ctx: TenantContext) => ({ ctx.now ).then(comment => comment.status !== GQLCOMMENT_STATUS.APPROVED - ? approve(ctx.mongo, ctx.redis, ctx.tenant, { + ? approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { commentID, commentRevisionID, moderatorID: ctx.user!.id, diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts new file mode 100644 index 000000000..20d8d5ca9 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts @@ -0,0 +1,38 @@ +import { + GQLMODERATION_QUEUE, + SubscriptionToCommentEnteredModerationQueueResolver, +} from "coral-server/graph/tenant/schema/__generated__/types"; + +import { createIterator } from "./helpers"; +import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types"; + +export interface CommentEnteredModerationQueueInput + extends SubscriptionPayload { + queue: GQLMODERATION_QUEUE; + commentID: string; + storyID: string; +} + +export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerationQueueResolver< + CommentEnteredModerationQueueInput +> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, { + filter: (source, { storyID, queue }) => { + // If we're filtering by storyID, then only send back comments with the + // specific storyID. + if (storyID && source.storyID !== storyID) { + return false; + } + + // If we're filtering by queue, then only send back comments from the + // specific queue. + if (queue && source.queue !== queue) { + return false; + } + + return true; + }, + resolve: ({ queue, commentID }, args, ctx) => ({ + queue: () => queue, + comment: () => ctx.loaders.Comments.comment.load(commentID), + }), +}); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts new file mode 100644 index 000000000..b75460cd3 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts @@ -0,0 +1,37 @@ +import { + GQLMODERATION_QUEUE, + SubscriptionToCommentLeftModerationQueueResolver, +} from "coral-server/graph/tenant/schema/__generated__/types"; + +import { createIterator } from "./helpers"; +import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types"; + +export interface CommentLeftModerationQueueInput extends SubscriptionPayload { + queue: GQLMODERATION_QUEUE; + commentID: string; + storyID: string; +} + +export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueueResolver< + CommentLeftModerationQueueInput +> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, { + filter: (source, { storyID, queue }) => { + // If we're filtering by storyID, then only send back comments with the + // specific storyID. + if (storyID && source.storyID !== storyID) { + return false; + } + + // If we're filtering by queue, then only send back comments from the + // specific queue. + if (queue && source.queue !== queue) { + return false; + } + + return true; + }, + resolve: ({ queue, commentID }, args, ctx) => ({ + queue: () => queue, + comment: () => ctx.loaders.Comments.comment.load(commentID), + }), +}); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts new file mode 100644 index 000000000..376602319 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts @@ -0,0 +1,40 @@ +import { + GQLCOMMENT_STATUS, + SubscriptionToCommentStatusUpdatedResolver, +} from "coral-server/graph/tenant/schema/__generated__/types"; + +import { createIterator } from "./helpers"; +import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types"; + +export interface CommentStatusUpdatedInput extends SubscriptionPayload { + newStatus: GQLCOMMENT_STATUS; + oldStatus: GQLCOMMENT_STATUS; + moderatorID: string | null; + commentID: string; +} + +export const commentStatusUpdated: SubscriptionToCommentStatusUpdatedResolver< + CommentStatusUpdatedInput +> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, { + filter: (source, { id }) => { + // If we're filtering by id, then only send back updates for the specified + // comment. + if (id && source.commentID !== id) { + return false; + } + + return true; + }, + resolve: ({ newStatus, oldStatus, moderatorID, commentID }, args, ctx) => ({ + newStatus: () => newStatus, + oldStatus: () => oldStatus, + moderator: () => { + if (moderatorID) { + return ctx.loaders.Users.user.load(moderatorID); + } + + return null; + }, + comment: () => ctx.loaders.Comments.comment.load(commentID), + }), +}); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/helpers.ts b/src/core/server/graph/tenant/resolvers/Subscription/helpers.ts new file mode 100644 index 000000000..0c0b09c25 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/helpers.ts @@ -0,0 +1,108 @@ +import { GraphQLResolveInfo } from "graphql"; +import { withFilter } from "graphql-subscriptions"; + +import TenantContext from "../../context"; +import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types"; + +type FilterFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => boolean | Promise; + +type Resolver = ( + source: TParent, + args: TArgs, + ctx: TenantContext, + info: GraphQLResolveInfo +) => TResult; + +interface SubscriptionResolver { + subscribe: Resolver>; + resolve?: Resolver; +} + +export function createTenantAsyncIterator( + channel: SUBSCRIPTION_CHANNELS +): Resolver> { + return (source, args, ctx) => + ctx.pubsub.asyncIterator( + createSubscriptionChannelName(ctx.tenant.id, channel) + ); +} + +export function createSubscriptionChannelName( + tenantID: string, + channel: SUBSCRIPTION_CHANNELS +): string { + return `TENANT[${tenantID}][${channel}]`; +} + +/** + * defaultFilterFn will perform filtering operations on the subscription + * responses to ensure that mutations issued by one user is not sent back as a + * subscription to the same requesting User, as they already implement the + * update via the mutation response. + * + * @param source the source for the document passed down, we don't actually need + * it here. + * @param args the arguments for the specific subscription operation, we don't + * actually need it here. + * @param ctx the context for the request, this contains the references we'll + * need to determine eligibility to send the subscription back or + * not. + */ +export function defaultFilterFn( + source: TParent, + args: TArgs, + ctx: TenantContext +): boolean { + if (source.clientID && ctx.clientID && source.clientID === ctx.clientID) { + return false; + } + + return true; +} + +/** + * Ensure that even when we're provided with a domain specific filtering + * function we respect the subscription id that is sent back with the request to + * prevent double responses. + */ +export function createFilterFn( + filter?: FilterFn +): FilterFn { + return filter + ? // Combine the filters, preferring the defaultFilterFn first. + (source, args, ctx, info) => { + if (!defaultFilterFn(source, args, ctx)) { + return false; + } + + return filter(source, args, ctx, info); + } + : defaultFilterFn; +} + +export interface CreateIteratorInput { + filter?: FilterFn; + resolve?: Resolver; +} + +export function createIterator< + TParent extends SubscriptionPayload, + TArgs, + TResult +>( + channel: SUBSCRIPTION_CHANNELS, + { filter, resolve }: CreateIteratorInput = {} +): SubscriptionResolver { + return { + subscribe: withFilter( + createTenantAsyncIterator(channel), + createFilterFn(filter) + ), + resolve, + }; +} diff --git a/src/core/server/graph/tenant/resolvers/Subscription/index.ts b/src/core/server/graph/tenant/resolvers/Subscription/index.ts new file mode 100644 index 000000000..b7fe431aa --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/index.ts @@ -0,0 +1,11 @@ +import { GQLSubscriptionTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { commentEnteredModerationQueue } from "./commentEnteredModerationQueue"; +import { commentLeftModerationQueue } from "./commentLeftModerationQueue"; +import { commentStatusUpdated } from "./commentStatusUpdated"; + +export const Subscription: GQLSubscriptionTypeResolver = { + commentEnteredModerationQueue, + commentLeftModerationQueue, + commentStatusUpdated, +}; diff --git a/src/core/server/graph/tenant/resolvers/Subscription/types.ts b/src/core/server/graph/tenant/resolvers/Subscription/types.ts new file mode 100644 index 000000000..83df84793 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/types.ts @@ -0,0 +1,35 @@ +import { CommentEnteredModerationQueueInput } from "./commentEnteredModerationQueue"; +import { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue"; +import { CommentStatusUpdatedInput } from "./commentStatusUpdated"; + +export enum SUBSCRIPTION_CHANNELS { + COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE", + COMMENT_LEFT_MODERATION_QUEUE = "COMMENT_LEFT_MODERATION_QUEUE", + COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED", +} + +export interface SubscriptionPayload { + clientID?: string; +} + +export interface SubscriptionType< + TChannel extends SUBSCRIPTION_CHANNELS, + TPayload extends SubscriptionPayload +> { + channel: TChannel; + payload: TPayload; +} + +export type SUBSCRIPTION_INPUT = + | SubscriptionType< + SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, + CommentEnteredModerationQueueInput + > + | SubscriptionType< + SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, + CommentLeftModerationQueueInput + > + | SubscriptionType< + SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, + CommentStatusUpdatedInput + >; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index efb786d13..7ff541270 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -25,6 +25,7 @@ import { Query } from "./Query"; import { RejectCommentPayload } from "./RejectCommentPayload"; import { Story } from "./Story"; import { StorySettings } from "./StorySettings"; +import { Subscription } from "./Subscription"; import { SuspensionStatus } from "./SuspensionStatus"; import { SuspensionStatusHistory } from "./SuspensionStatusHistory"; import { Tag } from "./Tag"; @@ -56,6 +57,7 @@ const Resolvers: GQLResolver = { RejectCommentPayload, Story, StorySettings, + Subscription, SuspensionStatus, SuspensionStatusHistory, Tag, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 1b1c6f28a..4a347f5eb 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -4459,3 +4459,121 @@ type Mutation { removeUserIgnore(input: RemoveUserIgnoreInput!): RemoveUserIgnorePayload! @auth(permit: [SUSPENDED, BANNED]) } + +################## +## Subscriptions +################## + +""" +CommentStatusUpdatedPayload is returned when a Comment has it's status updated +after it was created. +""" +type CommentStatusUpdatedPayload { + """ + newStatus is the new status assigned to the Comment. This status may not + match the status provided by `comment.status` due to race conditions in the + data loaders. + """ + newStatus: COMMENT_STATUS! + + """ + oldStatus is the old status that was previously assigned to the Comment. + """ + oldStatus: COMMENT_STATUS! + + """ + moderator is the User that updated the Comment's status. If null, then the + system assigned the new Comment status (for example, when a comment is edited + by the author, and now contains a banned word). + """ + moderator: User + + """ + comment is the updated Comment after the status has been updated. + """ + comment: Comment! +} + +""" +MODERATION_QUEUE references the specific ModerationQueue that a given Comment +can be associated with. +""" +enum MODERATION_QUEUE { + """ + UNMODERATED refers to the ModerationQueue for all Comments that have not been + moderated yet. + """ + UNMODERATED + + """ + REPORTED refers to the ModerationQueue for all Comments that have been + published, have not been moderated by a human yet, and have been reported by + a User via a flag. + """ + REPORTED + + """ + PENDING refers to the ModerationQueue for all Comments that were held back by + the system and require moderation in order to be published. + """ + PENDING +} + +""" +CommentEnteredModerationQueuePayload is returned when a Comment enters a +specific ModerationQueue. +""" +type CommentEnteredModerationQueuePayload { + """ + queue refers to the specific ModerationQueue that a given Comment entered. + """ + queue: MODERATION_QUEUE! + + """ + comment is the Comment that entered the ModerationQueue. + """ + comment: Comment! +} + +""" +CommentLeftModerationQueuePayload is returned when a Comment leaves a specific +ModerationQueue. +""" +type CommentLeftModerationQueuePayload { + """ + queue refers to the specific ModerationQueue that a given Comment left. + """ + queue: MODERATION_QUEUE! + + """ + comment is the Comment that left the ModerationQueue. + """ + comment: Comment! +} + +type Subscription { + """ + commentEnteredModerationQueue returns when a Comment enters a ModerationQueue. + Note that a Comment may enter multiple moderation queues. + """ + commentEnteredModerationQueue( + storyID: ID + queue: MODERATION_QUEUE + ): CommentEnteredModerationQueuePayload! @auth(roles: [MODERATOR, ADMIN]) + + """ + commentLeftModerationQueue returns when a Comment leaves a ModerationQueue. + Note that a Comment may leave multiple moderation queues. + """ + commentLeftModerationQueue( + storyID: ID + queue: MODERATION_QUEUE + ): CommentLeftModerationQueuePayload! @auth(roles: [MODERATOR, ADMIN]) + + """ + commentStatusUpdated returns when a Comment has it's status changed after + being created. + """ + commentStatusUpdated(id: ID): CommentStatusUpdatedPayload! + @auth(roles: [MODERATOR, ADMIN]) +} diff --git a/src/core/server/graph/tenant/subscriptions/publisher.ts b/src/core/server/graph/tenant/subscriptions/publisher.ts new file mode 100644 index 000000000..5736f8ddc --- /dev/null +++ b/src/core/server/graph/tenant/subscriptions/publisher.ts @@ -0,0 +1,28 @@ +import { RedisPubSub } from "graphql-redis-subscriptions"; + +import { createSubscriptionChannelName } from "coral-server/graph/tenant/resolvers/Subscription/helpers"; +import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types"; +import logger from "coral-server/logger"; + +export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise; + +/** + * createPublisher will create a new Publisher that can be used to send events + * over the pubsub broker to facilitate live updates. + * + * @param pubsub the pubsub broker to be used to facilitate the publish action + * @param tenantID the ID of the Tenant where the event will be published with + * @param clientID the ID of the client to de-duplicate mutation responses + */ +export const createPublisher = ( + pubsub: RedisPubSub, + tenantID: string, + clientID?: string +): Publisher => async ({ channel, payload }) => { + logger.trace({ channel, tenantID, clientID }, "publishing event"); + + return pubsub.publish(createSubscriptionChannelName(tenantID, channel), { + ...payload, + clientID, + }); +}; diff --git a/src/core/server/graph/tenant/subscriptions/server.ts b/src/core/server/graph/tenant/subscriptions/server.ts new file mode 100644 index 000000000..8f8e55fe9 --- /dev/null +++ b/src/core/server/graph/tenant/subscriptions/server.ts @@ -0,0 +1,222 @@ +import { + execute, + ExecutionResult, + GraphQLSchema, + parse, + subscribe, +} from "graphql"; +import http, { IncomingMessage } from "http"; +import { + ConnectionContext, + ExecutionParams, + OperationMessagePayload, + SubscriptionServer, +} from "subscriptions-transport-ws"; + +import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants"; +import { Omit } from "coral-common/types"; +import { AppOptions } from "coral-server/app"; +import { getHostname } from "coral-server/app/helpers/hostname"; +import { + createVerifiers, + verifyAndRetrieveUser, +} from "coral-server/app/middleware/passport/strategies/jwt"; +import { + CoralError, + InternalError, + TenantNotFoundError, +} from "coral-server/errors"; +import { + enrichError, + logError, + logQuery, +} from "coral-server/graph/common/extensions"; +import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers"; +import logger from "coral-server/logger"; +import { extractTokenFromRequest } from "coral-server/services/jwt"; + +import TenantContext, { TenantContextOptions } from "../context"; + +type OnConnectFn = ( + params: OperationMessagePayload, + socket: any, + context: ConnectionContext +) => Promise; + +export function extractTokenFromWSRequest( + connectionParams: OperationMessagePayload, + req: IncomingMessage +): string | null { + // Try to grab the token from the connection params if available. + if ( + typeof connectionParams[ACCESS_TOKEN_PARAM] === "string" && + connectionParams[ACCESS_TOKEN_PARAM].length > 0 + ) { + return connectionParams[ACCESS_TOKEN_PARAM]; + } + + // Try to get the access token from the request. + return extractTokenFromRequest(req); +} + +export function extractClientID(connectionParams: OperationMessagePayload) { + if ( + typeof connectionParams[CLIENT_ID_PARAM] === "string" && + connectionParams[CLIENT_ID_PARAM].length > 0 + ) { + return connectionParams[CLIENT_ID_PARAM]; + } + + return null; +} + +export type OnConnectOptions = Omit< + TenantContextOptions, + "tenant" | "signingConfig" | "disableCaching" +> & + Required>; + +export function onConnect(options: OnConnectOptions): OnConnectFn { + // Create the JWT verifiers that will be used to verify all the requests + // coming in. + const verifiers = createVerifiers(options); + + // Return the per-connection operation. + return async (connectionParams, socket) => { + try { + // Pull the upgrade request off of the connection. + const req: IncomingMessage = socket.upgradeReq; + + // Get the hostname of the request. + const hostname = getHostname(req); + if (!hostname) { + throw new Error("could not detect hostname"); + } + + // Get the Tenant for this hostname. + const tenant = await options.tenantCache.retrieveByDomain(hostname); + if (!tenant) { + throw new TenantNotFoundError(hostname); + } + + // Create some new options to store the tenant context details inside. + const opts: TenantContextOptions = { + ...options, + // Disable caching with this Context to ensure that every call (besides) + // to the tenant, is not cached, and is instead fresh. + disableCaching: true, + tenant, + }; + + // If the token is available, try to get the user. + const tokenString = extractTokenFromWSRequest(connectionParams, req); + if (tokenString) { + const user = await verifyAndRetrieveUser( + verifiers, + tenant, + tokenString + ); + if (user) { + opts.user = user; + } + } + + // Extract the users clientID from the request. + const clientID = extractClientID(connectionParams); + if (clientID) { + opts.clientID = clientID; + } + + return new TenantContext(opts); + } catch (err) { + logger.error({ err }, "could not setup websocket connection"); + + if (!(err instanceof CoralError)) { + err = new InternalError(err, "could not setup websocket connection"); + } + const { message } = err.serializeExtensions( + options.i18n.getDefaultBundle() + ); + + throw { message }; + } + }; +} + +export type FormatResponseOptions = Pick; + +export function formatResponse({ metrics }: FormatResponseOptions) { + return ( + value: ExecutionResult, + { context, query }: ExecutionParams + ) => { + // Parse the query in order to extract operation metadata. + if (typeof query === "string") { + query = parse(query); + } + + // Log out the query. + logQuery(context, query); + + // Increment the metrics if enabled. + if (metrics) { + // Get the request metadata. + const { operation, operationName } = getOperationMetadata(query); + if (operation && operationName) { + // Increment the graph query value, tagging with the name of the query. + metrics.executedGraphQueriesTotalCounter + .labels(operation, operationName) + .inc(); + } + } + + if (value.errors && value.errors.length > 0) { + return { + ...value, + errors: value.errors.map(err => { + const enriched = enrichError(context, err); + + // Log the error out. + logError(context, enriched); + + return enriched; + }), + }; + } + + return value; + }; +} + +export type OnOperationOptions = FormatResponseOptions; + +export function onOperation(options: OnOperationOptions) { + return (message: any, params: ExecutionParams) => { + // Attach the response formatter. + params.formatResponse = formatResponse(options); + + return params; + }; +} + +export type Options = OnConnectOptions & OnOperationOptions; + +export function createSubscriptionServer( + server: http.Server, + schema: GraphQLSchema, + options: Options +) { + return SubscriptionServer.create( + { + schema, + execute, + subscribe, + onConnect: onConnect(options), + onOperation: onOperation(options), + }, + { + server, + path: "/api/graphql/live", + } + ); +} diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f96085ad2..08922fb4a 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,18 +1,28 @@ import cluster from "cluster"; import express, { Express } from "express"; import { GraphQLSchema } from "graphql"; +import { RedisPubSub } from "graphql-redis-subscriptions"; import http from "http"; import { Db } from "mongodb"; import { AggregatorRegistry, collectDefaultMetrics } from "prom-client"; +import { SubscriptionServer } from "subscriptions-transport-ws"; import { LanguageCode } from "coral-common/helpers/i18n/locales"; -import { createApp, listenAndServe } from "coral-server/app"; +import { AppOptions, createApp, listenAndServe } from "coral-server/app"; +import { basicAuth } from "coral-server/app/middleware/basicAuth"; +import { noCacheMiddleware } from "coral-server/app/middleware/cacheHeaders"; +import { JSONErrorHandler } from "coral-server/app/middleware/error"; +import { accessLogger, errorLogger } from "coral-server/app/middleware/logging"; +import { notFoundMiddleware } from "coral-server/app/middleware/notFound"; import config, { Config } from "coral-server/config"; +import { createPubSubClient } from "coral-server/graph/common/subscriptions/pubsub"; import getTenantSchema from "coral-server/graph/tenant/schema"; +import { createSubscriptionServer } from "coral-server/graph/tenant/subscriptions/server"; import logger from "coral-server/logger"; import { createQueue, TaskQueue } from "coral-server/queue"; import { I18n } from "coral-server/services/i18n"; import { createJWTSigningConfig } from "coral-server/services/jwt"; +import { createMetrics } from "coral-server/services/metrics"; import { createMongoDB } from "coral-server/services/mongodb"; import { ensureIndexes } from "coral-server/services/mongodb/indexes"; import { @@ -21,11 +31,6 @@ import { createRedisClient, } from "coral-server/services/redis"; import TenantCache from "coral-server/services/tenant/cache"; -import { basicAuth } from "./app/middleware/basicAuth"; -import { noCacheMiddleware } from "./app/middleware/cacheHeaders"; -import { JSONErrorHandler } from "./app/middleware/error"; -import { accessLogger, errorLogger } from "./app/middleware/logging"; -import { notFoundMiddleware } from "./app/middleware/notFound"; export interface ServerOptions { /** @@ -55,12 +60,19 @@ class Server { // the requested port. public httpServer: http.Server; + // subscriptionServer is the running instance of the HTTP server that will + // bind to the requested port to serve websocket traffic. + public subscriptionServer: SubscriptionServer; + // tasks stores a reference to the queues that can process operations. private tasks: TaskQueue; // redis stores the redis connection used by the application. private redis: AugmentedRedis; + // pubsub stores the pubsub engine used by the application. + private pubsub: RedisPubSub; + // mongo stores the mongo connection used by the application. private mongo: Db; @@ -134,6 +146,12 @@ class Server { i18n: this.i18n, }); + // Create the pubsub client. + this.pubsub = createPubSubClient( + createRedisClient(this.config), + createRedisClient(this.config) + ); + // Setup the metrics collectors. collectDefaultMetrics({ timeout: 5000 }); } @@ -238,16 +256,13 @@ class Server { // Create the signing config. const signingConfig = createJWTSigningConfig(this.config); - // Only enable the metrics server if concurrency is set to 1. - const metrics = this.config.get("concurrency") === 1; - - // Disables the client routes to serve bundles etc. Useful for devleoping with + // Disables the client routes to serve bundles etc. Useful for developing with // Webpack Dev Server. const disableClientRoutes = this.config.get("disable_client_routes"); - // Create the Coral App, branching off from the parent app. - const app: Express = await createApp({ + const options: AppOptions = { parent, + pubsub: this.pubsub, mongo: this.mongo, redis: this.redis, signingConfig, @@ -257,16 +272,28 @@ class Server { i18n: this.i18n, mailerQueue: this.tasks.mailer, scraperQueue: this.tasks.scraper, - metrics, disableClientRoutes, - }); + }; + + // Only enable the metrics server if concurrency is set to 1. + if (this.config.get("concurrency") === 1) { + options.metrics = createMetrics(); + } + + // Create the Coral App, branching off from the parent app. + const app: Express = await createApp(options); // Start the application and store the resulting http.Server. The server // will return when the server starts listening. The NodeJS application will // not exit until all tasks are handled, which for an open socket, is never. this.httpServer = await listenAndServe(app, port); - // TODO: (wyattjoh) add the subscription handler here + // Setup subscriptions and attach it to the httpServer. + this.subscriptionServer = createSubscriptionServer( + this.httpServer, + this.schema, + options + ); logger.info({ port }, "now listening"); } diff --git a/src/core/server/models/user/helpers.ts b/src/core/server/models/user/helpers.ts index a3f3df44d..1f910cd90 100644 --- a/src/core/server/models/user/helpers.ts +++ b/src/core/server/models/user/helpers.ts @@ -11,6 +11,6 @@ export function roleIsStaff(role: GQLUSER_ROLE) { return false; } -export function userIsStaff(user: User) { +export function userIsStaff(user: Pick) { return roleIsStaff(user.role); } diff --git a/src/core/server/services/comments/actions.ts b/src/core/server/services/comments/actions.ts index b05cfbefd..bd5b85277 100644 --- a/src/core/server/services/comments/actions.ts +++ b/src/core/server/services/comments/actions.ts @@ -1,7 +1,9 @@ import { Db } from "mongodb"; import { Omit } from "coral-common/types"; +import { CommentNotFoundError } from "coral-server/errors"; import { GQLCOMMENT_FLAG_REPORTED_REASON } from "coral-server/graph/tenant/schema/__generated__/types"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; import { ACTION_TYPE, CommentAction, @@ -25,8 +27,9 @@ import { } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; +import { publishModerationQueueChanges } from "coral-server/services/events"; +import { AugmentedRedis } from "coral-server/services/redis"; -import { AugmentedRedis } from "../redis"; import { calculateCountsDiff } from "./moderation/counts"; export type CreateAction = CreateActionInput; @@ -78,14 +81,14 @@ export async function addCommentActionCounts( async function addCommentAction( mongo: Db, redis: AugmentedRedis, + publish: Publisher, tenant: Tenant, input: Omit, now = new Date() ): Promise> { const oldComment = await retrieveComment(mongo, tenant.id, input.commentID); if (!oldComment) { - // TODO: replace to match error returned by the models/comments.ts - throw new Error("comment not found"); + throw new CommentNotFoundError(input.commentID); } // Create the action creator input. @@ -105,12 +108,17 @@ async function addCommentAction( ...commentActions ); + const moderationQueue = calculateCountsDiff(oldComment, updatedComment); + // Calculate the new story counts. await updateStoryCounts(mongo, redis, tenant.id, updatedComment.storyID, { action: encodeActionCounts(...commentActions), - moderationQueue: calculateCountsDiff(oldComment, updatedComment), + moderationQueue, }); + // Publish changes to the queue. + publishModerationQueueChanges(publish, moderationQueue, updatedComment); + return updatedComment; } @@ -126,8 +134,7 @@ export async function removeCommentAction( // Get the Comment that we are leaving the Action on. 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"); + throw new CommentNotFoundError(input.commentID); } // Get the revision for the specific action being removed. @@ -198,6 +205,7 @@ export type CreateCommentReaction = Pick< export async function createReaction( mongo: Db, redis: AugmentedRedis, + publish: Publisher, tenant: Tenant, author: User, input: CreateCommentReaction, @@ -206,6 +214,7 @@ export async function createReaction( return addCommentAction( mongo, redis, + publish, tenant, { actionType: ACTION_TYPE.REACTION, @@ -241,6 +250,7 @@ export type CreateCommentDontAgree = Pick< export async function createDontAgree( mongo: Db, redis: AugmentedRedis, + publish: Publisher, tenant: Tenant, author: User, input: CreateCommentDontAgree, @@ -249,6 +259,7 @@ export async function createDontAgree( return addCommentAction( mongo, redis, + publish, tenant, { actionType: ACTION_TYPE.DONT_AGREE, @@ -287,6 +298,7 @@ export type CreateCommentFlag = Pick< export async function createFlag( mongo: Db, redis: AugmentedRedis, + publish: Publisher, tenant: Tenant, author: User, input: CreateCommentFlag, @@ -295,6 +307,7 @@ export async function createFlag( return addCommentAction( mongo, redis, + publish, tenant, { actionType: ACTION_TYPE.FLAG, diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index d6e7fd580..64ec7ba42 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,12 +1,21 @@ import { DateTime } from "luxon"; import { Db } from "mongodb"; +import { ERROR_TYPES } from "coral-common/errors"; import { Omit } from "coral-common/types"; +import { + CommentNotFoundError, + CoralError, + StoryNotFoundError, +} from "coral-server/errors"; +import { GQLTAG } from "coral-server/graph/tenant/schema/__generated__/types"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; import logger from "coral-server/logger"; import { encodeActionCounts, filterDuplicateActions, } from "coral-server/models/action/comment"; +import { createCommentModerationAction } from "coral-server/models/action/moderation/comment"; import { addCommentTag, createComment, @@ -19,6 +28,10 @@ import { retrieveComment, validateEditable, } from "coral-server/models/comment"; +import { + hasAncestors, + hasVisibleStatus, +} from "coral-server/models/comment/helpers"; import { retrieveStory, StoryCounts, @@ -26,20 +39,13 @@ import { } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; +import { + publishCommentStatusChanges, + publishModerationQueueChanges, +} from "coral-server/services/events"; +import { AugmentedRedis } from "coral-server/services/redis"; import { Request } from "coral-server/types/express"; -import { ERROR_TYPES } from "coral-common/errors"; -import { - CommentNotFoundError, - CoralError, - StoryNotFoundError, -} from "coral-server/errors"; -import { GQLTAG } from "coral-server/graph/tenant/schema/__generated__/types"; -import { - hasAncestors, - hasVisibleStatus, -} from "coral-server/models/comment/helpers"; -import { AugmentedRedis } from "../redis"; import { addCommentActions, CreateAction } from "./actions"; import { calculateCounts, calculateCountsDiff } from "./moderation/counts"; import { PhaseResult, processForModeration } from "./pipeline"; @@ -52,6 +58,7 @@ export type CreateComment = Omit< export async function create( mongo: Db, redis: AugmentedRedis, + publish: Publisher, tenant: Tenant, author: User, input: CreateComment, @@ -205,6 +212,10 @@ export async function create( log.trace({ actions: upsertedActions.length }, "added actions to comment"); } + const moderationQueue = calculateCounts(comment); + + // Publish changes to the queue. + publishModerationQueueChanges(publish, moderationQueue, comment); // Compile the changes we want to apply to the story counts. const storyCounts: Required> = { @@ -212,7 +223,7 @@ export async function create( status: { [status]: 1 }, // This comment is being created, so we can compute it raw from the comment // that we created. - moderationQueue: calculateCounts(comment), + moderationQueue, }; log.trace({ storyCounts }, "updating story status counts"); @@ -231,6 +242,7 @@ export type EditComment = Omit< export async function edit( mongo: Db, redis: AugmentedRedis, + publish: Publisher, tenant: Tenant, author: User, input: EditComment, @@ -346,19 +358,21 @@ export async function edit( ); } + // Compute the changes in queue counts. This looks at the action counts that + // are encoded, as well as the comment status's. We however may have had the + // comment status when we grabbed the updated comment after changing the + // action counts, so we extract the action counts out of the edited comment + // and use the status from the moderation decision. + const moderationQueue = calculateCountsDiff(oldComment, { + status, + actionCounts: editedComment.actionCounts, + }); + // Compile the changes we want to apply to the story counts. const storyCounts: Required> = { // Status is updated below if it has been changed. status: {}, - // Compute the changes in queue counts. This looks at the action counts that - // are encoded, as well as the comment status's. We however may have had the - // comment status when we grabbed the updated comment after changing the - // action counts, so we extract the action counts out of the edited comment - // and use the status from the moderation decision. - moderationQueue: calculateCountsDiff(oldComment, { - status, - actionCounts: editedComment.actionCounts, - }), + moderationQueue, }; if (oldComment.status !== editedComment.status) { @@ -368,6 +382,15 @@ export async function edit( // on the moderation pipeline. storyCounts.status[oldComment.status] = -1; storyCounts.status[status] = 1; + + // The comment status changed as a result of a pipeline operation, create a + // moderation action as a result. + await createCommentModerationAction(mongo, tenant.id, { + commentID: editedComment.id, + commentRevisionID: newRevision.id, + status: editedComment.status, + moderatorID: null, + }); } log.trace({ storyCounts }, "updating story status counts"); @@ -375,6 +398,17 @@ export async function edit( // Update the story counts as a result. await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts); + // Publish changes. + publishModerationQueueChanges(publish, moderationQueue, editedComment); + publishCommentStatusChanges( + publish, + oldComment.status, + editedComment.status, + editedComment.id, + // This is a comment that was edited, so it should not present a moderator. + null + ); + return editedComment; } diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index 493851fb4..c3e68de32 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -1,7 +1,9 @@ import { Db } from "mongodb"; import { Omit } from "coral-common/types"; +import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors"; import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; import logger from "coral-server/logger"; import { createCommentModerationAction, @@ -10,7 +12,12 @@ import { import { updateCommentStatus } from "coral-server/models/comment"; import { updateStoryCounts } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; +import { + publishCommentStatusChanges, + publishModerationQueueChanges, +} from "coral-server/services/events"; import { AugmentedRedis } from "coral-server/services/redis"; + import { calculateCountsDiff } from "./counts"; export type Moderate = Omit; @@ -20,6 +27,7 @@ const moderate = ( ) => async ( mongo: Db, redis: AugmentedRedis, + publish: Publisher, tenant: Tenant, input: Moderate ) => { @@ -41,8 +49,7 @@ const moderate = ( status ); if (!result) { - // TODO: wrap in better error? - throw new Error("specified comment not found"); + throw new CommentNotFoundError(input.commentID, input.commentRevisionID); } log.trace("updated comment status"); @@ -62,6 +69,19 @@ const moderate = ( "created the moderation action" ); + // Compute the queue difference as a result of the old status and the new + // status. + const moderationQueue = calculateCountsDiff( + { + status: result.oldStatus, + actionCounts: result.comment.actionCounts, + }, + { + status, + actionCounts: result.comment.actionCounts, + } + ); + // Update the story comment counts. const story = await updateStoryCounts( mongo, @@ -74,25 +94,24 @@ const moderate = ( [result.oldStatus]: -1, [status]: 1, }, - // Compute the queue difference as a result of the old status and the new - // status. - moderationQueue: calculateCountsDiff( - { - status: result.oldStatus, - actionCounts: result.comment.actionCounts, - }, - { - status, - actionCounts: result.comment.actionCounts, - } - ), + + moderationQueue, } ); if (!story) { - // TODO: wrap in better error? - throw new Error("specified story not found"); + throw new StoryNotFoundError(result.comment.storyID); } + // Publish changes. + publishModerationQueueChanges(publish, moderationQueue, result.comment); + publishCommentStatusChanges( + publish, + result.oldStatus, + status, + result.comment.id, + input.moderatorID + ); + log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts"); return result.comment; diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts new file mode 100644 index 000000000..dc16ef066 --- /dev/null +++ b/src/core/server/services/events/comments.ts @@ -0,0 +1,92 @@ +import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types"; +import { + GQLCOMMENT_STATUS, + GQLMODERATION_QUEUE, +} from "coral-server/graph/tenant/schema/__generated__/types"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import { Comment } from "coral-server/models/comment"; +import { CommentModerationQueueCounts } from "coral-server/models/story/counts"; + +export function publishCommentStatusChanges( + publish: Publisher, + oldStatus: GQLCOMMENT_STATUS, + newStatus: GQLCOMMENT_STATUS, + commentID: string, + moderatorID: string | null +) { + if (oldStatus !== newStatus) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, + payload: { + newStatus, + oldStatus, + commentID, + moderatorID, + }, + }); + } +} + +export function publishModerationQueueChanges( + publish: Publisher, + moderationQueue: Pick, + comment: Pick +) { + if (moderationQueue.queues.pending === 1) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, + payload: { + queue: GQLMODERATION_QUEUE.PENDING, + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } else if (moderationQueue.queues.pending === -1) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, + payload: { + queue: GQLMODERATION_QUEUE.PENDING, + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } + if (moderationQueue.queues.reported === 1) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, + payload: { + queue: GQLMODERATION_QUEUE.REPORTED, + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } else if (moderationQueue.queues.reported === -1) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, + payload: { + queue: GQLMODERATION_QUEUE.REPORTED, + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } + if (moderationQueue.queues.unmoderated === 1) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, + payload: { + queue: GQLMODERATION_QUEUE.UNMODERATED, + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } else if (moderationQueue.queues.unmoderated === -1) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, + payload: { + queue: GQLMODERATION_QUEUE.UNMODERATED, + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } +} diff --git a/src/core/server/services/events/index.ts b/src/core/server/services/events/index.ts new file mode 100644 index 000000000..222a6656c --- /dev/null +++ b/src/core/server/services/events/index.ts @@ -0,0 +1 @@ +export * from "./comments"; diff --git a/src/core/server/services/jwt/index.spec.ts b/src/core/server/services/jwt/index.spec.ts index d1c7869a3..4fb91cd3f 100644 --- a/src/core/server/services/jwt/index.spec.ts +++ b/src/core/server/services/jwt/index.spec.ts @@ -3,7 +3,7 @@ import sinon from "sinon"; import { Config } from "coral-server/config"; import { createJWTSigningConfig, - extractJWTFromRequest, + extractTokenFromRequest, } from "coral-server/services/jwt"; import { Request } from "coral-server/types/express"; @@ -16,22 +16,22 @@ describe("extractJWTFromRequest", () => { url: "", }; - expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(extractTokenFromRequest((req as any) as Request)).toEqual("token"); delete req.headers.authorization; - expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(extractTokenFromRequest((req as any) as Request)).toEqual(null); }); it("extracts the token from query string", () => { const req = { url: "", }; - expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(extractTokenFromRequest((req as any) as Request)).toEqual(null); req.url = "https://coral.coralproject.net/api?accessToken=token"; - expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(extractTokenFromRequest((req as any) as Request)).toEqual("token"); }); it("does not extract the token from query string when it's disabled", () => { @@ -39,7 +39,9 @@ describe("extractJWTFromRequest", () => { url: "https://coral.coralproject.net/api?accessToken=token", }; - expect(extractJWTFromRequest((req as any) as Request, true)).toEqual(null); + expect(extractTokenFromRequest((req as any) as Request, true)).toEqual( + null + ); }); }); diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index 82b75a447..30aabc66d 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -10,6 +10,7 @@ import { AuthenticationError, TokenInvalidError } from "coral-server/errors"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { Request } from "coral-server/types/express"; +import { IncomingMessage } from "http"; /** * The following Claim Names are registered in the IANA "JSON Web Token @@ -244,8 +245,8 @@ export async function signString( * @param req the request to extract the JWT from * @param excludeQuery when true, does not pull from the query params */ -export function extractJWTFromRequest( - req: Request, +export function extractTokenFromRequest( + req: Request | IncomingMessage, excludeQuery: boolean = false ) { const options: BearerOptions = { diff --git a/src/core/server/services/metrics/index.ts b/src/core/server/services/metrics/index.ts new file mode 100644 index 000000000..4b701d2f8 --- /dev/null +++ b/src/core/server/services/metrics/index.ts @@ -0,0 +1,44 @@ +import { Counter, Histogram } from "prom-client"; + +export interface Metrics { + executedGraphQueriesTotalCounter: Counter; + graphQLExecutionTimingsHistogram: Histogram; + httpRequestsTotal: Counter; + httpRequestDurationMilliseconds: Histogram; +} + +export function createMetrics(): Metrics { + // Configure the metrics handlers. + const executedGraphQueriesTotalCounter = new Counter({ + name: "coral_executed_graph_queries_total", + help: "number of GraphQL queries executed", + labelNames: ["operation_type", "operation_name"], + }); + + const graphQLExecutionTimingsHistogram = new Histogram({ + name: "coral_executed_graph_queries_timings", + help: "timings for execution times of GraphQL operations", + buckets: [0.1, 5, 15, 50, 100, 500], + labelNames: ["operation_type", "operation_name"], + }); + + const httpRequestsTotal = new Counter({ + name: "http_requests_total", + help: "Total number of HTTP requests made.", + labelNames: ["code", "method"], + }); + + const httpRequestDurationMilliseconds = new Histogram({ + name: "http_request_duration_milliseconds", + help: "Histogram of latencies for HTTP requests.", + buckets: [0.1, 5, 15, 50, 100, 500], + labelNames: ["method", "handler"], + }); + + return { + executedGraphQueriesTotalCounter, + graphQLExecutionTimingsHistogram, + httpRequestsTotal, + httpRequestDurationMilliseconds, + }; +} diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 08de25f2c..ae0fd6f75 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -337,10 +337,18 @@ moderate-comment-story = Story moderate-comment-moderateStory = Moderate Story moderate-comment-featureText = Feature moderate-comment-featuredText = Featured +moderate-comment-moderatedBy = Moderated By +moderate-comment-moderatedBySystem = System moderate-single-goToModerationQueues = Go to moderation queues moderate-single-singleCommentView = Single Comment View +moderate-queue-viewNew = + { $count -> + [1] View {$count} new comment + *[other] View {$count} new comments + } + ### Moderate Search Bar moderate-searchBar-allStories = All stories .title = All stories diff --git a/src/types/permit.d.ts b/src/types/permit.d.ts deleted file mode 100644 index c24f13a6b..000000000 --- a/src/types/permit.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -// TODO: (wyattjoh) following https://github.com/DefinitelyTyped/DefinitelyTyped/pull/29061 to merge then replace this with @types/permit. -declare module "permit" { - import { IncomingMessage, ServerResponse } from "http"; - - export interface PermitOptions { - scheme?: string; - proxy?: string; - realm?: string; - } - - export interface BearerOptions extends PermitOptions { - basic?: string; - header?: string; - query?: string; - } - - export class Permit { - constructor(options: PermitOptions); - check(req: IncomingMessage): void; - fail(res: ServerResponse): void; - } - - export class Bearer extends Permit { - constructor(options: BearerOptions); - check(req: IncomingMessage): string; - } - - export class Basic extends Permit { - check(req: IncomingMessage): [string, string]; - } -} \ No newline at end of file