Add per-document control over who can see viewer insights (#5594)

This commit is contained in:
Tom Moor
2023-07-23 12:01:36 -04:00
committed by GitHub
parent caf7333682
commit 479b805613
14 changed files with 192 additions and 73 deletions

View File

@@ -69,7 +69,7 @@ function InnerDocumentLink(
if (isActiveDocument && hasChildDocuments) {
void fetchChildDocuments(node.id);
}
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
}, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]);
const pathToNode = React.useMemo(
() => collection?.pathToDocument(node.id).map((entry) => entry.id),

View File

@@ -83,8 +83,11 @@ const Input = styled.label<{ width: number; height: number }>`
display: inline-block;
width: ${(props) => props.width}px;
height: ${(props) => props.height}px;
margin-right: 8px;
flex-shrink: 0;
&:not(:last-child) {
margin-right: 8px;
}
`;
const Slider = styled.span<{ width: number; height: number }>`

View File

@@ -14,7 +14,7 @@ type Props = {
*/
const Text = styled.p<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "auto")};
text-align: ${(props) => (props.dir ? props.dir : "initial")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary

View File

@@ -44,44 +44,77 @@ export default class Document extends ParanoidModel {
store: DocumentsStore;
@Field
@observable
collectionId?: string | null;
@Field
@observable
id: string;
/**
* The id of the collection that this document belongs to, if any.
*/
@Field
@observable
collectionId?: string | null;
/**
* The text content of the document as Markdown.
*/
@observable
text: string;
/**
* The title of the document.
*/
@Field
@observable
title: string;
/**
* Whether this is a template.
*/
@observable
template: boolean;
/**
* Whether the document layout is displayed full page width.
*/
@Field
@observable
fullWidth: boolean;
/**
* Whether team members can see who has viewed this document.
*/
@Field
@observable
insightsEnabled: boolean;
/**
* A reference to the template that this document was created from.
*/
@Field
@observable
templateId: string | undefined;
/**
* The id of the parent document that this is a child of, if any.
*/
@Field
@observable
parentDocumentId: string | undefined;
@observable
collaboratorIds: string[];
@observable
createdBy: User;
@observable
updatedBy: User;
@observable
publishedAt: string | undefined;
@observable
archivedAt: string;
url: string;

View File

@@ -12,9 +12,11 @@ import DocumentViews from "~/components/DocumentViews";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useTextSelection from "~/hooks/useTextSelection";
import { documentPath } from "~/utils/routeHelpers";
@@ -30,6 +32,7 @@ function Insights() {
const { editor } = useDocumentContext();
const text = editor?.getPlainText();
const stats = useTextStats(text ?? "", selectedText);
const can = usePolicy(document);
const documentViews = document ? views.inDocument(document.id) : [];
const onCloseInsights = () => {
@@ -83,57 +86,86 @@ function Insights() {
</List>
</Text>
</Content>
<Content column>
<Heading>{t("Contributors")}</Heading>
<Text type="secondary" size="small">
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
<br />
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />.
</Text>
<ListSpacing>
<PaginatedList
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model: User) => (
<ListItem
key={model.id}
title={model.name}
image={<Avatar model={model} size={32} />}
subtitle={
model.id === document.createdBy.id
? t("Creator")
: model.id === document.updatedBy.id
? t("Last edited")
: t("Previously edited")
}
border={false}
small
{document.insightsEnabled && (
<>
<Content column>
<Heading>{t("Contributors")}</Heading>
<Text type="secondary" size="small">
{t(`Created`)}{" "}
<Time dateTime={document.createdAt} addSuffix />.
<br />
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />.
</Text>
<ListSpacing>
<PaginatedList
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model: User) => (
<ListItem
key={model.id}
title={model.name}
image={<Avatar model={model} size={32} />}
subtitle={
model.id === document.createdBy.id
? t("Creator")
: model.id === document.updatedBy.id
? t("Last edited")
: t("Previously edited")
}
border={false}
small
/>
)}
/>
</ListSpacing>
</Content>
<Content column>
<Heading>{t("Views")}</Heading>
<Text type="secondary" size="small">
{documentViews.length <= 1
? t("No one else has viewed yet")
: t(
`Viewed {{ count }} times by {{ teamMembers }} people`,
{
count: documentViews.reduce(
(memo, view) => memo + view.count,
0
),
teamMembers: documentViews.length,
}
)}
.
</Text>
{documentViews.length > 1 && (
<ListSpacing>
<DocumentViews document={document} isOpen />
</ListSpacing>
)}
</Content>
</>
)}
{can.updateInsights && (
<Manage>
<Flex column>
<Text size="small" weight="bold">
{t("Viewer insights")}
</Text>
<Text type="secondary" size="small">
{t(
"As an admin you can manage if team members can see who has viewed this document"
)}
</Text>
</Flex>
<Switch
checked={document.insightsEnabled}
onChange={async (ev) => {
document.insightsEnabled = ev.currentTarget.checked;
await document.save();
}}
/>
</ListSpacing>
</Content>
<Content column>
<Heading>{t("Views")}</Heading>
<Text type="secondary" size="small">
{documentViews.length <= 1
? t("No one else has viewed yet")
: t(`Viewed {{ count }} times by {{ teamMembers }} people`, {
count: documentViews.reduce(
(memo, view) => memo + view.count,
0
),
teamMembers: documentViews.length,
})}
.
</Text>
{documentViews.length > 1 && (
<ListSpacing>
<DocumentViews document={document} isOpen />
</ListSpacing>
)}
</Content>
</Manage>
)}
</>
) : null}
</Sidebar>
@@ -166,6 +198,17 @@ function countWords(text: string): number {
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
}
const Manage = styled(Flex)`
border: 1px solid ${s("inputBorder")};
border-bottom-width: 2px;
border-radius: 8px;
margin: 16px;
padding: 16px 16px 0;
position: absolute;
bottom: 0;
`;
const ListSpacing = styled("div")`
margin-top: -0.5em;
margin-bottom: 0.5em;

View File

@@ -19,6 +19,8 @@ type Props = {
templateId?: string | null;
/** If the document should be displayed full-width on the screen */
fullWidth?: boolean;
/** Whether insights should be visible on the document */
insightsEnabled?: boolean;
/** Whether the text be appended to the end instead of replace */
append?: boolean;
/** Whether the document should be published to the collection */
@@ -46,6 +48,7 @@ export default async function documentUpdater({
editorVersion,
templateId,
fullWidth,
insightsEnabled,
append,
publish,
collectionId,
@@ -68,6 +71,9 @@ export default async function documentUpdater({
if (fullWidth !== undefined) {
document.fullWidth = fullWidth;
}
if (insightsEnabled !== undefined) {
document.insightsEnabled = insightsEnabled;
}
if (text !== undefined) {
document = DocumentHelper.applyMarkdownToDocument(document, text, append);
}

View File

@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn("documents", "insightsEnabled", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
},
async down (queryInterface, Sequelize) {
await queryInterface.removeColumn("documents", "insightsEnabled");
}
};

View File

@@ -196,6 +196,9 @@ class Document extends ParanoidModel {
@Column
fullWidth: boolean;
@Column
insightsEnabled: boolean;
@SimpleLength({
max: 255,
msg: `editorVersion must be 255 characters or less`,

View File

@@ -277,6 +277,20 @@ allow(User, "archive", Document, (user, document) => {
return user.teamId === document.teamId;
});
allow(User, "updateInsights", Document, (user, document) => {
if (!document || !document.isActive || document.isDraft) {
return false;
}
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
);
if (cannot(user, "update", document.collection)) {
return false;
}
return user.teamId === document.teamId;
});
allow(User, "unarchive", Document, (user, document) => {
if (!document) {
return false;

View File

@@ -41,6 +41,7 @@ async function presentDocument(
templateId: document.templateId,
collaboratorIds: [],
revision: document.revisionCount,
insightsEnabled: document.insightsEnabled,
fullWidth: document.fullWidth,
collectionId: undefined,
parentDocumentId: undefined,

View File

@@ -892,18 +892,8 @@ router.post(
auth(),
validate(T.DocumentsUpdateSchema),
async (ctx: APIContext<T.DocumentsUpdateReq>) => {
const {
id,
title,
text,
fullWidth,
publish,
templateId,
collectionId,
append,
apiVersion,
done,
} = ctx.input.body;
const { id, apiVersion, insightsEnabled, publish, collectionId, ...input } =
ctx.input.body;
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
const { user } = ctx.state.auth;
let collection: Collection | null | undefined;
@@ -915,6 +905,10 @@ router.post(
collection = document?.collection;
authorize(user, "update", document);
if (collection && insightsEnabled !== undefined) {
authorize(user, "updateInsights", document);
}
if (publish) {
if (!document.collectionId) {
assertPresent(
@@ -932,16 +926,12 @@ router.post(
await documentUpdater({
document,
user,
title,
text,
fullWidth,
...input,
publish,
collectionId,
append,
templateId,
insightsEnabled,
editorVersion,
transaction,
done,
ip: ctx.request.ip,
});

View File

@@ -189,6 +189,9 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
/** Boolean to denote if the doc should occupy full width */
fullWidth: z.boolean().optional(),
/** Boolean to denote if insights should be visible on the doc */
insightsEnabled: z.boolean().optional(),
/** Boolean to denote if the doc should be published */
publish: z.boolean().optional(),

View File

@@ -1,4 +1,5 @@
import Router from "koa-router";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import validate from "@server/middlewares/validate";
@@ -23,6 +24,11 @@ router.post(
userId: user.id,
});
authorize(user, "read", document);
if (!document.insightsEnabled) {
throw ValidationError("Insights are not enabled for this document");
}
const views = await View.findByDocument(documentId, { includeSuspended });
ctx.body = {

View File

@@ -511,6 +511,8 @@
"No one else has viewed yet": "No one else has viewed yet",
"Viewed {{ count }} times by {{ teamMembers }} people": "Viewed {{ count }} time by {{ teamMembers }} people",
"Viewed {{ count }} times by {{ teamMembers }} people_plural": "Viewed {{ count }} times by {{ teamMembers }} people",
"Viewer insights": "Viewer insights",
"As an admin you can manage if team members can see who has viewed this document": "As an admin you can manage if team members can see who has viewed this document",
"Sorry, it looks like you dont have permission to access the document": "Sorry, it looks like you dont have permission to access the document",
"Sorry, this document is too large - edits will no longer be persisted.": "Sorry, this document is too large - edits will no longer be persisted.",
"Sorry, the last change could not be persisted please reload the page": "Sorry, the last change could not be persisted please reload the page",