mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf04b4c2a6 | ||
|
|
9022532525 | ||
|
|
0fa27ae41b | ||
|
|
0065875f12 |
Generated
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "6.2.0",
|
||||
"version": "6.2.3",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -31137,7 +31137,7 @@
|
||||
},
|
||||
"chalk": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
|
||||
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "6.2.0",
|
||||
"version": "6.2.3",
|
||||
"author": "The Coral Project",
|
||||
"homepage": "https://coralproject.net/",
|
||||
"sideEffects": [
|
||||
|
||||
@@ -718,16 +718,6 @@ export default function createWebpackConfig(
|
||||
filename: "storyButton.html",
|
||||
template: paths.appEmbedStoryButtonHTML,
|
||||
inject: "head",
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "amp.html",
|
||||
template: paths.appEmbedAMPHTML,
|
||||
inject: "head",
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "storyAMP.html",
|
||||
template: paths.appEmbedStoryAMPHTML,
|
||||
inject: false,
|
||||
})
|
||||
),
|
||||
...ifBuild(
|
||||
|
||||
@@ -53,8 +53,6 @@ export default {
|
||||
appEmbedHTML: resolveSrc("core/client/embed/index.html"),
|
||||
appEmbedStoryHTML: resolveSrc("core/client/embed/story.html"),
|
||||
appEmbedStoryButtonHTML: resolveSrc("core/client/embed/storyButton.html"),
|
||||
appEmbedAMPHTML: resolveSrc("core/client/embed/amp.html"),
|
||||
appEmbedStoryAMPHTML: resolveSrc("core/client/embed/storyAMP.html"),
|
||||
|
||||
appDistStatic: resolveApp("dist/static"),
|
||||
appPublic: resolveApp("public"),
|
||||
|
||||
@@ -5,6 +5,7 @@ exports[`get access token from url 1`] = `
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": \\"eyJraWQiOiI5NmM4MDY2YS1kOTg3LTQyODItODNmOS1kYTUxNjc5N2Y5ZmMiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==.\\",
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
|
||||
\\"redirectPath\\": null,
|
||||
\\"authView\\": \\"SIGN_IN\\",
|
||||
@@ -24,6 +25,9 @@ exports[`init local state 1`] = `
|
||||
\\"client:root.local\\": {
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": null,
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": null,
|
||||
\\"redirectPath\\": null,
|
||||
\\"authView\\": \\"SIGN_IN\\",
|
||||
\\"authError\\": null
|
||||
|
||||
@@ -5,6 +5,7 @@ exports[`get access token from url 1`] = `
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": \\"eyJraWQiOiI5NmM4MDY2YS1kOTg3LTQyODItODNmOS1kYTUxNjc5N2Y5ZmMiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==.\\",
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
|
||||
\\"view\\": \\"SIGN_IN\\",
|
||||
\\"error\\": null
|
||||
@@ -23,6 +24,9 @@ exports[`init local state 1`] = `
|
||||
\\"client:root.local\\": {
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": null,
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": null,
|
||||
\\"view\\": \\"SIGN_IN\\",
|
||||
\\"error\\": null
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface Config {
|
||||
enableDeprecatedEvents?: boolean;
|
||||
/** Allow setting className of body tag inside iframe */
|
||||
bodyClassName?: string;
|
||||
amp?: boolean;
|
||||
}
|
||||
|
||||
export function createStreamEmbed(config: Config): StreamEmbed {
|
||||
@@ -33,7 +32,7 @@ export function createStreamEmbed(config: Config): StreamEmbed {
|
||||
return create({
|
||||
title: "Coral Embed Stream",
|
||||
storyID: config.storyID || query.storyID,
|
||||
storyURL: config.storyURL || query.storyURL || resolveStoryURL(),
|
||||
storyURL: config.storyURL || resolveStoryURL(),
|
||||
commentID: config.commentID || query.commentID,
|
||||
id: config.id || "coral-embed-stream",
|
||||
rootURL: config.rootURL || getLocationOrigin(),
|
||||
@@ -42,6 +41,5 @@ export function createStreamEmbed(config: Config): StreamEmbed {
|
||||
accessToken: config.accessToken,
|
||||
bodyClassName: config.bodyClassName,
|
||||
enableDeprecatedEvents: config.enableDeprecatedEvents,
|
||||
amp: config.amp,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface StreamEmbedConfig {
|
||||
accessToken?: string;
|
||||
bodyClassName?: string;
|
||||
enableDeprecatedEvents?: boolean;
|
||||
amp?: boolean;
|
||||
}
|
||||
|
||||
export class StreamEmbed {
|
||||
@@ -138,7 +137,7 @@ export class StreamEmbed {
|
||||
|
||||
const streamDecorators: ReadonlyArray<Decorator> = [
|
||||
withIOSSafariWidthWorkaround,
|
||||
withAutoHeight(Boolean(this.config.amp)),
|
||||
withAutoHeight,
|
||||
withClickEvent,
|
||||
withSetCommentID,
|
||||
withEventEmitter(
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Coral – AMP Embed Stream</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="coralStreamEmbed" style="max-width: 640px; margin: 0 auto"></div>
|
||||
<script>
|
||||
const CoralStreamEmbed = Coral.createStreamEmbed({
|
||||
id: "coralStreamEmbed",
|
||||
rootURL: "http://localhost:8080",
|
||||
amp: true,
|
||||
});
|
||||
window.CoralStreamEmbed = CoralStreamEmbed;
|
||||
CoralStreamEmbed.render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +1,12 @@
|
||||
import { Decorator } from "./types";
|
||||
|
||||
const withAutoHeight: (amp: boolean) => Decorator = (amp) => (pym) => {
|
||||
const withAutoHeight: Decorator = (pym) => {
|
||||
// Resize parent iframe height when child height changes
|
||||
let cachedHeight: string;
|
||||
pym.onMessage("height", (height: string) => {
|
||||
if (height !== cachedHeight) {
|
||||
pym.iframe.style.height = `${height}px`;
|
||||
cachedHeight = height;
|
||||
if (amp) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
sentinel: "amp",
|
||||
type: "embed-size",
|
||||
height: Number.parseInt(height, 10) > 100 ? height : 100,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Coral – Embed Stream</title>
|
||||
<title>Coral 5.0 – Embed Stream</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
@@ -16,10 +16,9 @@
|
||||
<body>
|
||||
<p style="text-align: center">
|
||||
<a href="/admin">Admin</a> | <a href="/story.html">Story</a> |
|
||||
<a href="/storyButton.html">Story With Button</a> |
|
||||
<a href="/storyAMP.html"> AMP</a>
|
||||
<a href="/storyButton.html">Story With Button</a>
|
||||
</p>
|
||||
<h1 style="text-align: center">Coral – Embed Stream</h1>
|
||||
<h1 style="text-align: center">Coral 5.0 – Embed Stream</h1>
|
||||
<div id="coralStreamEmbed" style="max-width: 640px; margin: 0 auto"></div>
|
||||
<script>
|
||||
const CoralStreamEmbed = Coral.createStreamEmbed({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Coral – Embed Stream – Story</title>
|
||||
<title>Coral 5.0 – Embed Stream – Story</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||
@@ -16,10 +16,9 @@
|
||||
<body>
|
||||
<p style="text-align: center">
|
||||
<a href="/admin">Admin</a> | <a href="/">Default</a> |
|
||||
<a href="/storyButton.html">Story With Button</a> |
|
||||
<a href="/storyAMP.html"> AMP</a>
|
||||
<a href="/storyButton.html">Story With Button</a>
|
||||
</p>
|
||||
<h1 style="text-align: center">Coral – Story</h1>
|
||||
<h1 style="text-align: center">Coral 5.0 – Story</h1>
|
||||
<p>
|
||||
<a href="#coralStreamEmbed"><span class="coral-count"></span></a>
|
||||
</p>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<!doctype html>
|
||||
<html amp lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script async src="https://cdn.ampproject.org/v0.js"></script>
|
||||
<script async custom-element="amp-iframe" src="https://cdn.ampproject.org/v0/amp-iframe-0.1.js"></script>
|
||||
<title>Coral AMP</title>
|
||||
<link rel="canonical" href="https://amp.dev/documentation/guides-and-tutorials/start/create/basic_markup/">
|
||||
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
|
||||
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
|
||||
<style amp-custom>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0 100px 50px 100px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p style="text-align: center">
|
||||
<a href="/admin">Admin</a> | <a href="/">Default</a> | <a href="/story.html">Story</a> |
|
||||
<a href="/storyButton.html">Story With Button</a>
|
||||
</p>
|
||||
<h1 style="text-align: center">Coral – AMP</h1>
|
||||
<amp-iframe
|
||||
width=600 height=140
|
||||
layout="responsive"
|
||||
sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-forms"
|
||||
resizable
|
||||
src="http://127.0.0.1:8080/amp.html?storyURL=http://localhost:8080/storyAMP.html">
|
||||
<div placeholder></div>
|
||||
<div overflow tabindex=0 role=button aria-label="Read more">Read more</div>
|
||||
</amp-iframe>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Coral – Embed Stream – Story with Button</title>
|
||||
<title>Coral 5.0 – Embed Stream – Story with Button</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||
@@ -16,10 +16,9 @@
|
||||
<body>
|
||||
<p style="text-align: center">
|
||||
<a href="/admin">Admin</a> | <a href="/">Default</a> |
|
||||
<a href="/story.html">Story</a> |
|
||||
<a href="/storyAMP.html"> AMP</a>
|
||||
<a href="/story.html">Story</a>
|
||||
</p>
|
||||
<h1 style="text-align: center">Coral – Story with Button</h1>
|
||||
<h1 style="text-align: center">Coral 5.0 – Story with Button</h1>
|
||||
<p>
|
||||
<a href="#coralStreamEmbed"><span class="coral-count"></span></a>
|
||||
</p>
|
||||
|
||||
@@ -5,11 +5,6 @@ import { Claims, computeExpiresIn, parseAccessTokenClaims } from "./helpers";
|
||||
*/
|
||||
const ACCESS_TOKEN_KEY = "coral:v1:accessToken";
|
||||
|
||||
/**
|
||||
* storage is the Storage used to retrieve/update/delete access tokens on.
|
||||
*/
|
||||
const storage = localStorage;
|
||||
|
||||
export interface AuthState {
|
||||
/**
|
||||
* accessToken is the access token issued by the server.
|
||||
@@ -46,7 +41,7 @@ function parseAccessToken(accessToken: string) {
|
||||
export function retrieveAccessToken() {
|
||||
try {
|
||||
// Get the access token from storage.
|
||||
const accessToken = storage.getItem(ACCESS_TOKEN_KEY);
|
||||
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (!accessToken) {
|
||||
// Looks like the access token wasn't in storage.
|
||||
return;
|
||||
@@ -66,7 +61,7 @@ export function retrieveAccessToken() {
|
||||
export function storeAccessToken(accessToken: string) {
|
||||
try {
|
||||
// Update the access token in storage.
|
||||
storage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
} catch (err) {
|
||||
// TODO: (wyattjoh) add error reporting around this error
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -79,7 +74,7 @@ export function storeAccessToken(accessToken: string) {
|
||||
|
||||
export function deleteAccessToken() {
|
||||
try {
|
||||
storage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
} catch (err) {
|
||||
// TODO: (wyattjoh) add error reporting around this error
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -38,10 +38,10 @@ export function initLocalBaseState(
|
||||
root.setLinkedRecord(local, "local");
|
||||
|
||||
// Update the access token properties.
|
||||
local.setValue(auth?.accessToken, "accessToken");
|
||||
local.setValue(auth?.accessToken || null, "accessToken");
|
||||
|
||||
// Update the claims.
|
||||
local.setValue(auth?.claims.exp, "accessTokenExp");
|
||||
local.setValue(auth?.claims.jti, "accessTokenJTI");
|
||||
local.setValue(auth?.claims.exp || null, "accessTokenExp");
|
||||
local.setValue(auth?.claims.jti || null, "accessTokenJTI");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GQLSTORY_MODE } from "coral-framework/schema";
|
||||
import CLASSES from "coral-stream/classes";
|
||||
import { Icon, MatchMedia, Tab, TabBar } from "coral-ui/components";
|
||||
|
||||
type TabValue = "COMMENTS" | "PROFILE" | "CONFIGURE" | "%future added value";
|
||||
type TabValue = "COMMENTS" | "PROFILE" | "%future added value";
|
||||
|
||||
export interface Props {
|
||||
activeTab: TabValue;
|
||||
@@ -52,12 +52,7 @@ const AppTabBar: FunctionComponent<Props> = (props) => {
|
||||
</Tab>
|
||||
)}
|
||||
{props.showConfigureTab && (
|
||||
<Tab
|
||||
className={cn(CLASSES.tabBar.configure, {
|
||||
[CLASSES.tabBar.activeTab]: props.activeTab === "CONFIGURE",
|
||||
})}
|
||||
tabID="CONFIGURE"
|
||||
>
|
||||
<Tab className={CLASSES.tabBar.configure} tabID="CONFIGURE">
|
||||
<MatchMedia gteWidth="sm">
|
||||
{(matches) =>
|
||||
matches ? (
|
||||
|
||||
@@ -10,12 +10,7 @@
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
// This is only loaded in development, so include the React devtools hooks.
|
||||
try {
|
||||
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
}
|
||||
catch {
|
||||
console.warn("React Devtools Global Hook not loaded")
|
||||
}
|
||||
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
</script>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
@@ -12,6 +12,9 @@ exports[`init local state 1`] = `
|
||||
\\"client:root.local\\": {
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": null,
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": null,
|
||||
\\"commentsOrderBy\\": \\"CREATED_AT_DESC\\",
|
||||
\\"authPopup\\": {
|
||||
\\"__ref\\": \\"client:root.local.authPopup\\"
|
||||
|
||||
@@ -99,7 +99,7 @@ function generateContentSecurityPolicy(allowedOrigins: string[]) {
|
||||
|
||||
// Only the domains that are allowed by the tenant may embed Coral.
|
||||
directives.frameAncestors =
|
||||
allowedOrigins.length > 0 ? ["'self'", ...allowedOrigins] : ["'none'"];
|
||||
allowedOrigins.length > 0 ? allowedOrigins : ["'none'"];
|
||||
|
||||
// Build the directive.
|
||||
const directive = builder({ directives });
|
||||
|
||||
@@ -17,11 +17,6 @@ import Entrypoints, { Entrypoint } from "../helpers/entrypoints";
|
||||
export interface ClientTargetHandlerOptions {
|
||||
defaultLocale: LanguageCode;
|
||||
|
||||
/**
|
||||
* viewTemplate is the html template to use.
|
||||
*/
|
||||
viewTemplate?: string;
|
||||
|
||||
/**
|
||||
* mongo is used when trying to infer a site from the request.
|
||||
*/
|
||||
@@ -78,7 +73,6 @@ const clientHandler = ({
|
||||
entrypoint,
|
||||
enableCustomCSS,
|
||||
defaultLocale,
|
||||
viewTemplate = "client",
|
||||
}: ClientTargetHandlerOptions): RequestHandler => (req, res, next) => {
|
||||
// Provide configuration to the frontend in the HTML.
|
||||
const config = {
|
||||
@@ -92,7 +86,7 @@ const clientHandler = ({
|
||||
}
|
||||
|
||||
res.render(
|
||||
viewTemplate,
|
||||
"client",
|
||||
{ staticURI, entrypoint, enableCustomCSS, locale, config },
|
||||
(err, html) => {
|
||||
if (err) {
|
||||
@@ -110,7 +104,10 @@ const clientHandler = ({
|
||||
);
|
||||
};
|
||||
|
||||
function loadEntrypoints(manifestFile: string) {
|
||||
export function mountClientRoutes(
|
||||
router: Router,
|
||||
{ staticURI, tenantCache, defaultLocale, mongo }: MountClientRouteOptions
|
||||
) {
|
||||
// TODO: (wyattjoh) figure out a better way of referencing paths.
|
||||
// Load the entrypoint manifest.
|
||||
const manifest = path.join(
|
||||
@@ -122,17 +119,9 @@ function loadEntrypoints(manifestFile: string) {
|
||||
"..",
|
||||
"dist",
|
||||
"static",
|
||||
manifestFile
|
||||
"asset-manifest.json"
|
||||
);
|
||||
return Entrypoints.fromFile(manifest);
|
||||
}
|
||||
|
||||
export function mountClientRoutes(
|
||||
router: Router,
|
||||
{ staticURI, tenantCache, defaultLocale, mongo }: MountClientRouteOptions
|
||||
) {
|
||||
const manifest = "asset-manifest.json";
|
||||
const entrypoints = loadEntrypoints(manifest);
|
||||
const entrypoints = Entrypoints.fromFile(manifest);
|
||||
if (!entrypoints) {
|
||||
logger.error(
|
||||
{ manifest },
|
||||
@@ -140,17 +129,6 @@ export function mountClientRoutes(
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const embedManifest = "embed-asset-manifest.json";
|
||||
const embedEntrypoints = loadEntrypoints(embedManifest);
|
||||
if (!embedEntrypoints) {
|
||||
logger.error(
|
||||
{ manifest: embedManifest },
|
||||
"could not load the generated manifest, client routes will remain un-mounted"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tenant identification middleware.
|
||||
router.use(
|
||||
tenantMiddleware({
|
||||
@@ -160,16 +138,6 @@ export function mountClientRoutes(
|
||||
);
|
||||
|
||||
// Add the embed targets.
|
||||
router.use(
|
||||
"/embed/stream/amp",
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
entrypoint: embedEntrypoints.get("main"),
|
||||
defaultLocale,
|
||||
mongo,
|
||||
viewTemplate: "amp",
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/embed/stream",
|
||||
createClientTargetRouter({
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{% import "macros.html" as macros %}
|
||||
{% extends "templates/base.html" %}
|
||||
|
||||
{% block title %}Coral AMP{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>body { margin: 0; }</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id='coralStreamEmbed'></div>
|
||||
{% if entrypoint.js %}
|
||||
{% for asset in entrypoint.js %}
|
||||
{{ macros.js(asset.src, asset.integrity, staticURI) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<script>
|
||||
const CoralStreamEmbed = Coral.createStreamEmbed({
|
||||
id: "coralStreamEmbed",
|
||||
amp: true,
|
||||
});
|
||||
window.CoralStreamEmbed = CoralStreamEmbed;
|
||||
CoralStreamEmbed.render();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,6 @@
|
||||
import * as story from "coral-server/models/story";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSTORY_MODE,
|
||||
GQLStorySettingsTypeResolver,
|
||||
} from "../schema/__generated__/types";
|
||||
import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types";
|
||||
|
||||
import { LiveConfigurationInput } from "./LiveConfiguration";
|
||||
|
||||
@@ -35,18 +30,7 @@ export const StorySettings: GQLStorySettingsTypeResolver<StorySettingsInput> = {
|
||||
};
|
||||
},
|
||||
// FEATURE_FLAG:ENABLE_QA
|
||||
mode: (s, input, ctx) => {
|
||||
if (s.mode) {
|
||||
return s.mode;
|
||||
}
|
||||
|
||||
// FEATURE_FLAG:DEFAULT_QA_STORY_MODE
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.DEFAULT_QA_STORY_MODE)) {
|
||||
return GQLSTORY_MODE.QA;
|
||||
}
|
||||
|
||||
return GQLSTORY_MODE.COMMENTS;
|
||||
},
|
||||
mode: (s, input, ctx) => story.resolveStoryMode(s, ctx.tenant),
|
||||
experts: (s, input, ctx) => {
|
||||
if (s.expertIDs) {
|
||||
return ctx.loaders.Users.user.loadMany(s.expertIDs);
|
||||
|
||||
@@ -2,9 +2,14 @@ import { DateTime } from "luxon";
|
||||
import { URL } from "url";
|
||||
|
||||
import { parseQuery, stringifyQuery } from "coral-common/utils";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { hasFeatureFlag, Tenant } from "coral-server/models/tenant";
|
||||
|
||||
import { Story } from ".";
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSTORY_MODE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { Story } from "./story";
|
||||
|
||||
/**
|
||||
* getURLWithCommentID returns the url with the comment id.
|
||||
@@ -61,3 +66,19 @@ export function getStoryClosedAt(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveStoryMode(
|
||||
storySettings: Story["settings"],
|
||||
tenant: Pick<Tenant, "featureFlags">
|
||||
) {
|
||||
if (storySettings.mode) {
|
||||
return storySettings.mode;
|
||||
}
|
||||
|
||||
// FEATURE_FLAG:DEFAULT_QA_STORY_MODE
|
||||
if (hasFeatureFlag(tenant, GQLFEATURE_FLAG.DEFAULT_QA_STORY_MODE)) {
|
||||
return GQLSTORY_MODE.QA;
|
||||
}
|
||||
|
||||
return GQLSTORY_MODE.COMMENTS;
|
||||
}
|
||||
|
||||
@@ -1,683 +1,2 @@
|
||||
import { Db, MongoError } from "mongodb";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { DeepPartial, FirstDeepPartial } from "coral-common/types";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import {
|
||||
DuplicateStoryIDError,
|
||||
DuplicateStoryURLError,
|
||||
StoryNotFoundError,
|
||||
} from "coral-server/errors";
|
||||
import {
|
||||
Connection,
|
||||
ConnectionInput,
|
||||
Query,
|
||||
resolveConnection,
|
||||
} from "coral-server/models/helpers";
|
||||
import { GlobalModerationSettings } from "coral-server/models/settings";
|
||||
import { TenantResource } from "coral-server/models/tenant";
|
||||
import { stories as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
import {
|
||||
GQLSTORY_MODE,
|
||||
GQLStoryMetadata,
|
||||
GQLStorySettings,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
createEmptyRelatedCommentCounts,
|
||||
RelatedCommentCounts,
|
||||
updateRelatedCommentCounts,
|
||||
} from "../comment/counts";
|
||||
|
||||
export * from "./story";
|
||||
export * from "./helpers";
|
||||
|
||||
export interface StreamModeSettings {
|
||||
/**
|
||||
* mode is whether the story stream is in commenting or Q&A mode.
|
||||
* This will determine the appearance of the stream and how it functions.
|
||||
* This is an optional parameter and if unset, defaults to commenting.
|
||||
*/
|
||||
mode?: GQLSTORY_MODE;
|
||||
|
||||
/**
|
||||
* experts are used during Q&A mode to assign users to answer questions
|
||||
* on a Q&A stream. It is an optional parameter and is only used when
|
||||
* the story stream is in Q&A mode.
|
||||
*/
|
||||
expertIDs?: string[];
|
||||
}
|
||||
|
||||
export type StorySettings = StreamModeSettings &
|
||||
GlobalModerationSettings &
|
||||
Pick<GQLStorySettings, "messageBox" | "mode" | "experts">;
|
||||
|
||||
export type StoryMetadata = GQLStoryMetadata;
|
||||
|
||||
export interface Story extends TenantResource {
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* url is the URL to the Story page.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* metadata stores the scraped metadata from the Story page.
|
||||
*/
|
||||
metadata?: StoryMetadata;
|
||||
|
||||
/**
|
||||
* scrapedAt is the Time that the Story had it's metadata scraped at.
|
||||
*/
|
||||
scrapedAt?: Date;
|
||||
|
||||
/**
|
||||
* commentCounts stores all the comment counters.
|
||||
*/
|
||||
commentCounts: RelatedCommentCounts;
|
||||
|
||||
/**
|
||||
* settings provides a point where the settings can be overridden for a
|
||||
* specific Story.
|
||||
*/
|
||||
settings: DeepPartial<StorySettings>;
|
||||
|
||||
/**
|
||||
* closedAt is the date that the Story was forced closed at, or false to
|
||||
* indicate that the story was re-opened.
|
||||
*/
|
||||
closedAt?: Date | false;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the Story was added to the Coral database.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* lastCommentedAt is the last time someone commented on this story.
|
||||
*/
|
||||
lastCommentedAt?: Date;
|
||||
|
||||
/**
|
||||
* siteID references the site the story belongs to
|
||||
*/
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export interface UpsertStoryInput {
|
||||
id?: string;
|
||||
url: string;
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export interface UpsertStoryResult {
|
||||
story: Story;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function upsertStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id = uuid(), url, siteID }: UpsertStoryInput,
|
||||
now = new Date()
|
||||
): Promise<UpsertStoryResult> {
|
||||
// Create the story, optionally sourcing the id from the input, additionally
|
||||
// porting in the tenantID.
|
||||
const story: Story = {
|
||||
id,
|
||||
url,
|
||||
tenantID,
|
||||
siteID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
// Perform the find and update operation to try and find and or create the
|
||||
// story.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
url,
|
||||
tenantID,
|
||||
},
|
||||
{ $setOnInsert: story },
|
||||
{
|
||||
// Create the object if it doesn't already exist.
|
||||
upsert: true,
|
||||
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new Story, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
// The story will either be found (via `result.value`) or upserted (via
|
||||
// `story`).
|
||||
story: result.value || story,
|
||||
|
||||
// The story was upserted if the value isn't provided.
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
if (err instanceof MongoError && err.code === 11000) {
|
||||
throw new DuplicateStoryIDError(err, id, url);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FindStoryInput {
|
||||
id?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export async function findStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindStoryInput
|
||||
) {
|
||||
if (id) {
|
||||
return retrieveStory(mongo, tenantID, id);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return retrieveStoryByURL(mongo, tenantID, url);
|
||||
}
|
||||
|
||||
// Story can't be found with that ID/URL combination and scraping is
|
||||
// disabled, so we fail here.
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FindOrCreateStoryInput {
|
||||
id?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface FindOrCreateStoryResult {
|
||||
story: Story | null;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function findOrCreateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindOrCreateStoryInput,
|
||||
siteID: string | null,
|
||||
now = new Date()
|
||||
): Promise<FindOrCreateStoryResult> {
|
||||
if (id) {
|
||||
if (url && siteID) {
|
||||
// The URL was specified, this is an upsert operation.
|
||||
return upsertStory(
|
||||
mongo,
|
||||
tenantID,
|
||||
{
|
||||
id,
|
||||
url,
|
||||
siteID,
|
||||
},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
// The URL was not specified, this is a lookup operation.
|
||||
const story = await retrieveStory(mongo, tenantID, id);
|
||||
|
||||
// Return the result object.
|
||||
return {
|
||||
story,
|
||||
wasUpserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// The ID was not specified, this is an upsert operation. Check to see that
|
||||
// the URL exists.
|
||||
if (!url) {
|
||||
throw new Error("cannot upsert an story without the url");
|
||||
}
|
||||
|
||||
if (!siteID) {
|
||||
throw new Error("cannot upsert story without site ID");
|
||||
}
|
||||
|
||||
return upsertStory(mongo, tenantID, { url, siteID }, now);
|
||||
}
|
||||
|
||||
export type CreateStoryInput = Partial<
|
||||
Pick<Story, "metadata" | "scrapedAt" | "closedAt">
|
||||
> & {
|
||||
siteID: string;
|
||||
};
|
||||
|
||||
export async function createStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
url: string,
|
||||
input: CreateStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the story.
|
||||
const story: Story = {
|
||||
...input,
|
||||
id,
|
||||
url,
|
||||
tenantID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
// Insert the story into the database.
|
||||
await collection(mongo).insertOne(story);
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
if (err instanceof MongoError && err.code === 11000) {
|
||||
throw new DuplicateStoryURLError(err, url, id);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Return the created story.
|
||||
return story;
|
||||
}
|
||||
|
||||
export async function retrieveStoryByURL(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
url: string
|
||||
) {
|
||||
return collection(mongo).findOne({ url, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveStory(mongo: Db, tenantID: string, id: string) {
|
||||
return collection(mongo).findOne({ id, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveManyStories(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
ids: string[]
|
||||
) {
|
||||
const cursor = collection(mongo).find({
|
||||
id: { $in: ids },
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const stories = await cursor.toArray();
|
||||
|
||||
return ids.map((id) => stories.find((story) => story.id === id) || null);
|
||||
}
|
||||
|
||||
export async function retrieveManyStoriesByURL(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
urls: string[]
|
||||
) {
|
||||
const cursor = collection(mongo).find({
|
||||
url: { $in: urls },
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const stories = await cursor.toArray();
|
||||
|
||||
return urls.map((url) => stories.find((story) => story.url === url) || null);
|
||||
}
|
||||
|
||||
export type UpdateStoryInput = Omit<
|
||||
Partial<Story>,
|
||||
"id" | "tenantID" | "closedAt" | "createdAt" | "siteID"
|
||||
>;
|
||||
|
||||
export async function updateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
input: UpdateStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Only update fields that have been updated.
|
||||
const update = {
|
||||
$set: {
|
||||
...dotize(input, { embedArrays: true }),
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
update,
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
if (input.url && err instanceof MongoError && err.code === 11000) {
|
||||
throw new DuplicateStoryURLError(err, input.url, id);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
export type UpdateStorySettingsInput = DeepPartial<StorySettings>;
|
||||
|
||||
export async function updateStorySettings(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
input: UpdateStorySettingsInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Only update fields that have been updated.
|
||||
const update = {
|
||||
$set: {
|
||||
...dotize({ settings: input }, { embedArrays: true }),
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
update,
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function openStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{
|
||||
$set: {
|
||||
closedAt: false,
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function closeStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{
|
||||
$set: {
|
||||
closedAt: now,
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function removeStory(mongo: Db, tenantID: string, id: string) {
|
||||
const result = await collection(mongo).findOneAndDelete({
|
||||
id,
|
||||
tenantID,
|
||||
});
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeStories will remove the stories specified by the set of id's.
|
||||
*/
|
||||
export async function removeStories(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
ids: string[]
|
||||
) {
|
||||
return collection(mongo).deleteMany({
|
||||
tenantID,
|
||||
id: {
|
||||
$in: ids,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type StoryConnectionInput = ConnectionInput<Story>;
|
||||
|
||||
export async function retrieveStoryConnection(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: StoryConnectionInput
|
||||
): Promise<Readonly<Connection<Readonly<Story>>>> {
|
||||
// Create the query.
|
||||
const query = new Query(collection(mongo)).where({ tenantID });
|
||||
|
||||
// If a filter is being applied, filter it as well.
|
||||
if (input.filter) {
|
||||
query.where(input.filter);
|
||||
}
|
||||
|
||||
return retrieveConnection(input, query);
|
||||
}
|
||||
|
||||
async function retrieveConnection(
|
||||
input: StoryConnectionInput,
|
||||
query: Query<Story>
|
||||
): Promise<Readonly<Connection<Readonly<Story>>>> {
|
||||
// Apply the pagination arguments to the query.
|
||||
query.orderBy({ createdAt: -1 });
|
||||
if (input.after) {
|
||||
query.where({ createdAt: { $lt: input.after as Date } });
|
||||
}
|
||||
|
||||
// Return a connection.
|
||||
return resolveConnection(query, input, (story) => story.createdAt);
|
||||
}
|
||||
|
||||
export async function retrieveActiveStories(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
limit: number
|
||||
) {
|
||||
const stories = await collection(mongo)
|
||||
.find({
|
||||
tenantID,
|
||||
// We limit this query to stories that have the following field. This
|
||||
// allows us to use the index.
|
||||
lastCommentedAt: {
|
||||
$exists: true,
|
||||
},
|
||||
})
|
||||
.sort({ lastCommentedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
return stories;
|
||||
}
|
||||
|
||||
export async function updateStoryLastCommentedAt(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
now: Date
|
||||
) {
|
||||
await collection(mongo).updateOne(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
lastCommentedAt: now,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateStoryCounts will update the comment counts for the story indicated.
|
||||
*
|
||||
* @param mongo mongodb database handle
|
||||
* @param tenantID ID of the Tenant where the Story is on
|
||||
* @param id the ID of the Story that we are updating counts on
|
||||
* @param commentCounts the counts that we are updating
|
||||
*/
|
||||
export const updateStoryCounts = (
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
commentCounts: FirstDeepPartial<RelatedCommentCounts>
|
||||
) => updateRelatedCommentCounts(collection(mongo), tenantID, id, commentCounts);
|
||||
|
||||
export async function addExpert(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
userID: string
|
||||
) {
|
||||
const story = await collection(mongo).findOne({ tenantID, id: storyID });
|
||||
if (!story) {
|
||||
throw new StoryNotFoundError(storyID);
|
||||
}
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$addToSet: {
|
||||
"settings.expertIDs": userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("unable to add expert to story");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function removeExpert(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
userID: string
|
||||
) {
|
||||
const story = await collection(mongo).findOne({ tenantID, id: storyID });
|
||||
if (!story) {
|
||||
throw new StoryNotFoundError(storyID);
|
||||
}
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$pull: {
|
||||
"settings.expertIDs": userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("unable to remove expert from story");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function setStoryMode(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
mode: GQLSTORY_MODE
|
||||
) {
|
||||
const story = await collection(mongo).findOne({ tenantID, id: storyID });
|
||||
if (!story) {
|
||||
throw new StoryNotFoundError(storyID);
|
||||
}
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"settings.mode": mode,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("unable to enable Q&A on story");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveStorySections will return the sections used by stories in the
|
||||
* database for a given Tenant sorted alphabetically.
|
||||
*
|
||||
* @param mongo the database connection to use to retrieve the data
|
||||
* @param tenantID the ID of the Tenant that we're retrieving data
|
||||
*/
|
||||
export async function retrieveStorySections(
|
||||
mongo: Db,
|
||||
tenantID: string
|
||||
): Promise<string[]> {
|
||||
const results: Array<string | null> = await collection(
|
||||
mongo
|
||||
).distinct("metadata.section", { tenantID });
|
||||
|
||||
// We perform the type assertion here because we know that after filtering out
|
||||
// the null entries, the resulting array can not contain null.
|
||||
return results.filter((section) => section !== null).sort() as string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
import { Db, MongoError } from "mongodb";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { DeepPartial, FirstDeepPartial } from "coral-common/types";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import {
|
||||
DuplicateStoryIDError,
|
||||
DuplicateStoryURLError,
|
||||
StoryNotFoundError,
|
||||
} from "coral-server/errors";
|
||||
import {
|
||||
Connection,
|
||||
ConnectionInput,
|
||||
Query,
|
||||
resolveConnection,
|
||||
} from "coral-server/models/helpers";
|
||||
import { GlobalModerationSettings } from "coral-server/models/settings";
|
||||
import { TenantResource } from "coral-server/models/tenant";
|
||||
import { stories as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
import {
|
||||
GQLSTORY_MODE,
|
||||
GQLStoryMetadata,
|
||||
GQLStorySettings,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
createEmptyRelatedCommentCounts,
|
||||
RelatedCommentCounts,
|
||||
updateRelatedCommentCounts,
|
||||
} from "../comment/counts";
|
||||
|
||||
export * from "./helpers";
|
||||
|
||||
export interface StreamModeSettings {
|
||||
/**
|
||||
* mode is whether the story stream is in commenting or Q&A mode.
|
||||
* This will determine the appearance of the stream and how it functions.
|
||||
* This is an optional parameter and if unset, defaults to commenting.
|
||||
*/
|
||||
mode?: GQLSTORY_MODE;
|
||||
|
||||
/**
|
||||
* experts are used during Q&A mode to assign users to answer questions
|
||||
* on a Q&A stream. It is an optional parameter and is only used when
|
||||
* the story stream is in Q&A mode.
|
||||
*/
|
||||
expertIDs?: string[];
|
||||
}
|
||||
|
||||
export type StorySettings = StreamModeSettings &
|
||||
GlobalModerationSettings &
|
||||
Pick<GQLStorySettings, "messageBox" | "mode" | "experts">;
|
||||
|
||||
export type StoryMetadata = GQLStoryMetadata;
|
||||
|
||||
export interface Story extends TenantResource {
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* url is the URL to the Story page.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* metadata stores the scraped metadata from the Story page.
|
||||
*/
|
||||
metadata?: StoryMetadata;
|
||||
|
||||
/**
|
||||
* scrapedAt is the Time that the Story had it's metadata scraped at.
|
||||
*/
|
||||
scrapedAt?: Date;
|
||||
|
||||
/**
|
||||
* commentCounts stores all the comment counters.
|
||||
*/
|
||||
commentCounts: RelatedCommentCounts;
|
||||
|
||||
/**
|
||||
* settings provides a point where the settings can be overridden for a
|
||||
* specific Story.
|
||||
*/
|
||||
settings: DeepPartial<StorySettings>;
|
||||
|
||||
/**
|
||||
* closedAt is the date that the Story was forced closed at, or false to
|
||||
* indicate that the story was re-opened.
|
||||
*/
|
||||
closedAt?: Date | false;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the Story was added to the Coral database.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* lastCommentedAt is the last time someone commented on this story.
|
||||
*/
|
||||
lastCommentedAt?: Date;
|
||||
|
||||
/**
|
||||
* siteID references the site the story belongs to
|
||||
*/
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export interface UpsertStoryInput {
|
||||
id?: string;
|
||||
url: string;
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export interface UpsertStoryResult {
|
||||
story: Story;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function upsertStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id = uuid(), url, siteID }: UpsertStoryInput,
|
||||
now = new Date()
|
||||
): Promise<UpsertStoryResult> {
|
||||
// Create the story, optionally sourcing the id from the input, additionally
|
||||
// porting in the tenantID.
|
||||
const story: Story = {
|
||||
id,
|
||||
url,
|
||||
tenantID,
|
||||
siteID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
// Perform the find and update operation to try and find and or create the
|
||||
// story.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
url,
|
||||
tenantID,
|
||||
},
|
||||
{ $setOnInsert: story },
|
||||
{
|
||||
// Create the object if it doesn't already exist.
|
||||
upsert: true,
|
||||
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new Story, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
// The story will either be found (via `result.value`) or upserted (via
|
||||
// `story`).
|
||||
story: result.value || story,
|
||||
|
||||
// The story was upserted if the value isn't provided.
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
if (err instanceof MongoError && err.code === 11000) {
|
||||
throw new DuplicateStoryIDError(err, id, url);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FindStoryInput {
|
||||
id?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export async function findStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindStoryInput
|
||||
) {
|
||||
if (id) {
|
||||
return retrieveStory(mongo, tenantID, id);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return retrieveStoryByURL(mongo, tenantID, url);
|
||||
}
|
||||
|
||||
// Story can't be found with that ID/URL combination and scraping is
|
||||
// disabled, so we fail here.
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FindOrCreateStoryInput {
|
||||
id?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface FindOrCreateStoryResult {
|
||||
story: Story | null;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function findOrCreateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindOrCreateStoryInput,
|
||||
siteID: string | null,
|
||||
now = new Date()
|
||||
): Promise<FindOrCreateStoryResult> {
|
||||
if (id) {
|
||||
if (url && siteID) {
|
||||
// The URL was specified, this is an upsert operation.
|
||||
return upsertStory(
|
||||
mongo,
|
||||
tenantID,
|
||||
{
|
||||
id,
|
||||
url,
|
||||
siteID,
|
||||
},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
// The URL was not specified, this is a lookup operation.
|
||||
const story = await retrieveStory(mongo, tenantID, id);
|
||||
|
||||
// Return the result object.
|
||||
return {
|
||||
story,
|
||||
wasUpserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// The ID was not specified, this is an upsert operation. Check to see that
|
||||
// the URL exists.
|
||||
if (!url) {
|
||||
throw new Error("cannot upsert an story without the url");
|
||||
}
|
||||
|
||||
if (!siteID) {
|
||||
throw new Error("cannot upsert story without site ID");
|
||||
}
|
||||
|
||||
return upsertStory(mongo, tenantID, { url, siteID }, now);
|
||||
}
|
||||
|
||||
export type CreateStoryInput = Partial<
|
||||
Pick<Story, "metadata" | "scrapedAt" | "closedAt">
|
||||
> & {
|
||||
siteID: string;
|
||||
};
|
||||
|
||||
export async function createStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
url: string,
|
||||
input: CreateStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the story.
|
||||
const story: Story = {
|
||||
...input,
|
||||
id,
|
||||
url,
|
||||
tenantID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
// Insert the story into the database.
|
||||
await collection(mongo).insertOne(story);
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
if (err instanceof MongoError && err.code === 11000) {
|
||||
throw new DuplicateStoryURLError(err, url, id);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Return the created story.
|
||||
return story;
|
||||
}
|
||||
|
||||
export async function retrieveStoryByURL(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
url: string
|
||||
) {
|
||||
return collection(mongo).findOne({ url, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveStory(mongo: Db, tenantID: string, id: string) {
|
||||
return collection(mongo).findOne({ id, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveManyStories(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
ids: string[]
|
||||
) {
|
||||
const cursor = collection(mongo).find({
|
||||
id: { $in: ids },
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const stories = await cursor.toArray();
|
||||
|
||||
return ids.map((id) => stories.find((story) => story.id === id) || null);
|
||||
}
|
||||
|
||||
export async function retrieveManyStoriesByURL(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
urls: string[]
|
||||
) {
|
||||
const cursor = collection(mongo).find({
|
||||
url: { $in: urls },
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const stories = await cursor.toArray();
|
||||
|
||||
return urls.map((url) => stories.find((story) => story.url === url) || null);
|
||||
}
|
||||
|
||||
export type UpdateStoryInput = Omit<
|
||||
Partial<Story>,
|
||||
"id" | "tenantID" | "closedAt" | "createdAt" | "siteID"
|
||||
>;
|
||||
|
||||
export async function updateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
input: UpdateStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Only update fields that have been updated.
|
||||
const update = {
|
||||
$set: {
|
||||
...dotize(input, { embedArrays: true }),
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
update,
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
if (input.url && err instanceof MongoError && err.code === 11000) {
|
||||
throw new DuplicateStoryURLError(err, input.url, id);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
export type UpdateStorySettingsInput = DeepPartial<StorySettings>;
|
||||
|
||||
export async function updateStorySettings(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
input: UpdateStorySettingsInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Only update fields that have been updated.
|
||||
const update = {
|
||||
$set: {
|
||||
...dotize({ settings: input }, { embedArrays: true }),
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
update,
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function openStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{
|
||||
$set: {
|
||||
closedAt: false,
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function closeStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{
|
||||
$set: {
|
||||
closedAt: now,
|
||||
// Always update the updated at time.
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function removeStory(mongo: Db, tenantID: string, id: string) {
|
||||
const result = await collection(mongo).findOneAndDelete({
|
||||
id,
|
||||
tenantID,
|
||||
});
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeStories will remove the stories specified by the set of id's.
|
||||
*/
|
||||
export async function removeStories(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
ids: string[]
|
||||
) {
|
||||
return collection(mongo).deleteMany({
|
||||
tenantID,
|
||||
id: {
|
||||
$in: ids,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type StoryConnectionInput = ConnectionInput<Story>;
|
||||
|
||||
export async function retrieveStoryConnection(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: StoryConnectionInput
|
||||
): Promise<Readonly<Connection<Readonly<Story>>>> {
|
||||
// Create the query.
|
||||
const query = new Query(collection(mongo)).where({ tenantID });
|
||||
|
||||
// If a filter is being applied, filter it as well.
|
||||
if (input.filter) {
|
||||
query.where(input.filter);
|
||||
}
|
||||
|
||||
return retrieveConnection(input, query);
|
||||
}
|
||||
|
||||
async function retrieveConnection(
|
||||
input: StoryConnectionInput,
|
||||
query: Query<Story>
|
||||
): Promise<Readonly<Connection<Readonly<Story>>>> {
|
||||
// Apply the pagination arguments to the query.
|
||||
query.orderBy({ createdAt: -1 });
|
||||
if (input.after) {
|
||||
query.where({ createdAt: { $lt: input.after as Date } });
|
||||
}
|
||||
|
||||
// Return a connection.
|
||||
return resolveConnection(query, input, (story) => story.createdAt);
|
||||
}
|
||||
|
||||
export async function retrieveActiveStories(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
limit: number
|
||||
) {
|
||||
const stories = await collection(mongo)
|
||||
.find({
|
||||
tenantID,
|
||||
// We limit this query to stories that have the following field. This
|
||||
// allows us to use the index.
|
||||
lastCommentedAt: {
|
||||
$exists: true,
|
||||
},
|
||||
})
|
||||
.sort({ lastCommentedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
return stories;
|
||||
}
|
||||
|
||||
export async function updateStoryLastCommentedAt(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
now: Date
|
||||
) {
|
||||
await collection(mongo).updateOne(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
lastCommentedAt: now,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateStoryCounts will update the comment counts for the story indicated.
|
||||
*
|
||||
* @param mongo mongodb database handle
|
||||
* @param tenantID ID of the Tenant where the Story is on
|
||||
* @param id the ID of the Story that we are updating counts on
|
||||
* @param commentCounts the counts that we are updating
|
||||
*/
|
||||
export const updateStoryCounts = (
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
commentCounts: FirstDeepPartial<RelatedCommentCounts>
|
||||
) => updateRelatedCommentCounts(collection(mongo), tenantID, id, commentCounts);
|
||||
|
||||
export async function addExpert(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
userID: string
|
||||
) {
|
||||
const story = await collection(mongo).findOne({ tenantID, id: storyID });
|
||||
if (!story) {
|
||||
throw new StoryNotFoundError(storyID);
|
||||
}
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$addToSet: {
|
||||
"settings.expertIDs": userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("unable to add expert to story");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function removeExpert(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
userID: string
|
||||
) {
|
||||
const story = await collection(mongo).findOne({ tenantID, id: storyID });
|
||||
if (!story) {
|
||||
throw new StoryNotFoundError(storyID);
|
||||
}
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$pull: {
|
||||
"settings.expertIDs": userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("unable to remove expert from story");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function setStoryMode(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string,
|
||||
mode: GQLSTORY_MODE
|
||||
) {
|
||||
const story = await collection(mongo).findOne({ tenantID, id: storyID });
|
||||
if (!story) {
|
||||
throw new StoryNotFoundError(storyID);
|
||||
}
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id: storyID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"settings.mode": mode,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("unable to enable Q&A on story");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveStorySections will return the sections used by stories in the
|
||||
* database for a given Tenant sorted alphabetically.
|
||||
*
|
||||
* @param mongo the database connection to use to retrieve the data
|
||||
* @param tenantID the ID of the Tenant that we're retrieving data
|
||||
*/
|
||||
export async function retrieveStorySections(
|
||||
mongo: Db,
|
||||
tenantID: string
|
||||
): Promise<string[]> {
|
||||
const results: Array<string | null> = await collection(
|
||||
mongo
|
||||
).distinct("metadata.section", { tenantID });
|
||||
|
||||
// We perform the type assertion here because we know that after filtering out
|
||||
// the null entries, the resulting array can not contain null.
|
||||
return results.filter((section) => section !== null).sort() as string[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDepth } from "coral-server/models/comment";
|
||||
import { resolveStoryMode } from "coral-server/models/story";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -12,11 +13,12 @@ import {
|
||||
export const tagExpertAnswers: IntermediateModerationPhase = ({
|
||||
author,
|
||||
story,
|
||||
tenant,
|
||||
comment,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (
|
||||
// If we're in Q&A mode...
|
||||
story.settings.mode === GQLSTORY_MODE.QA &&
|
||||
resolveStoryMode(story.settings, tenant) === GQLSTORY_MODE.QA &&
|
||||
// And we have experts for this story...
|
||||
story.settings.expertIDs &&
|
||||
// And the author is in expert list...
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveStoryMode } from "coral-server/models/story";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -11,10 +12,10 @@ import {
|
||||
export const tagUnansweredQuestions: IntermediateModerationPhase = ({
|
||||
comment,
|
||||
story,
|
||||
now,
|
||||
tenant,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// We only show unanswered tags in Q&A.
|
||||
if (story.settings.mode !== GQLSTORY_MODE.QA) {
|
||||
if (resolveStoryMode(story.settings, tenant) !== GQLSTORY_MODE.QA) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { ERROR_TYPES } from "coral-common/errors";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
CommentNotFoundError,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
hasPublishedStatus,
|
||||
} from "coral-server/models/comment/helpers";
|
||||
import {
|
||||
resolveStoryMode,
|
||||
retrieveStory,
|
||||
Story,
|
||||
updateStoryLastCommentedAt,
|
||||
@@ -71,7 +71,7 @@ const markCommentAsAnswered = async (
|
||||
now: Date
|
||||
) => {
|
||||
// We only process this if we're in Q&A mode.
|
||||
if (story.settings.mode !== GQLSTORY_MODE.QA) {
|
||||
if (resolveStoryMode(story.settings, tenant) !== GQLSTORY_MODE.QA) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user