From 1b1cd1c8d4a4be8618abf8bd22083fafaa0b6b16 Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Wed, 29 Mar 2023 06:24:32 +0530 Subject: [PATCH] API to fetch users who have read/write permission on a document collection (#5047) --- app/editor/components/MentionMenu.tsx | 20 +- app/stores/MembershipsStore.ts | 1 + app/stores/UsersStore.ts | 19 ++ server/routes/api/documents/documents.test.ts | 251 ++++++++++++++++++ server/routes/api/documents/documents.ts | 54 ++++ server/routes/api/documents/schema.ts | 9 + 6 files changed, 349 insertions(+), 5 deletions(-) diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index 7b29105d1..3bd7b5904 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -1,9 +1,11 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; import { v4 } from "uuid"; import { MenuItem } from "@shared/editor/types"; import { MentionType } from "@shared/types"; +import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import User from "~/models/User"; import Avatar from "~/components/Avatar"; import Flex from "~/components/Flex"; @@ -33,21 +35,28 @@ type Props = Omit< "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch" >; -function MentionMenu({ search, ...rest }: Props) { +function MentionMenu({ search, isActive, ...rest }: Props) { const [items, setItems] = React.useState([]); const { t } = useTranslation(); const { users, auth } = useStores(); + const location = useLocation(); + const documentId = parseDocumentSlug(location.pathname); const { view } = useEditor(); const { data, request } = useRequest( React.useCallback( - () => users.fetchPage({ query: search, filter: "active" }), - [users, search] + () => + documentId + ? users.fetchDocumentUsers({ id: documentId, query: search }) + : Promise.resolve([]), + [users, documentId, search] ) ); React.useEffect(() => { - request(); - }, [request]); + if (isActive) { + request(); + } + }, [request, isActive]); React.useEffect(() => { if (data) { @@ -85,6 +94,7 @@ function MentionMenu({ search, ...rest }: Props) { return ( { userId, }); this.remove(`${userId}-${collectionId}`); + this.rootStore.users.remove(userId); } @action diff --git a/app/stores/UsersStore.ts b/app/stores/UsersStore.ts index 7289c54a6..8efb9b5e2 100644 --- a/app/stores/UsersStore.ts +++ b/app/stores/UsersStore.ts @@ -159,6 +159,25 @@ export default class UsersStore extends BaseStore { return res.data; }; + @action + fetchDocumentUsers = async (params: { + id: string; + query?: string; + }): Promise => { + try { + const res = await client.post("/documents.users", params); + invariant(res?.data, "User list not available"); + let response: User[] = []; + runInAction("DocumentsStore#fetchUsers", () => { + response = res.data.map(this.add); + this.addPolicies(res.policies); + }); + return response; + } catch (err) { + return Promise.resolve([]); + } + }; + @action async delete(user: User, options: Record = {}) { super.delete(user, options); diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index e8c512a43..29a71aac6 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -8,6 +8,8 @@ import { CollectionUser, SearchQuery, Event, + User, + CollectionGroup, } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; import { @@ -18,6 +20,7 @@ import { buildDraftDocument, buildViewer, buildTeam, + buildGroup, } from "@server/test/factories"; import { seed, getTestServer } from "@server/test/support"; @@ -3107,3 +3110,251 @@ describe("#documents.unpublish", () => { expect(res.status).toEqual(401); }); }); + +describe("#documents.users", () => { + it("should return document users", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const [alan, bret, ken] = await Promise.all([ + buildUser({ + name: "Alan Kay", + teamId: user.teamId, + }), + buildUser({ + name: "Bret Victor", + teamId: user.teamId, + }), + buildUser({ + name: "Ken Thompson", + teamId: user.teamId, + }), + ]); + + // add people to collection + await Promise.all([ + CollectionUser.create({ + collectionId: collection.id, + userId: alan.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + CollectionUser.create({ + collectionId: collection.id, + userId: bret.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + CollectionUser.create({ + collectionId: collection.id, + userId: ken.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + ]); + + const res = await server.post("/api/documents.users", { + body: { + token: user.getJwtToken(), + id: document.id, + }, + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.length).toBe(3); + + const memberIds = body.data.map((u: User) => u.id); + expect(memberIds).toContain(alan.id); + expect(memberIds).toContain(bret.id); + expect(memberIds).toContain(ken.id); + }); + + it("should return document users with names matching the search query", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const [alan, bret, ken, jamie] = await Promise.all([ + buildUser({ + name: "Alan Kay", + teamId: user.teamId, + }), + buildUser({ + name: "Bret Victor", + teamId: user.teamId, + }), + buildUser({ + name: "Ken Thompson", + teamId: user.teamId, + }), + buildUser({ + name: "Jamie Zawinsky", + teamId: user.teamId, + }), + ]); + const group = await buildGroup({ + name: "Hackers", + createdById: user.id, + teamId: user.teamId, + }); + + // add people to group + await Promise.all([ + group.$add("user", ken, { + through: { + createdById: user.id, + }, + }), + group.$add("user", jamie, { + through: { + createdById: user.id, + }, + }), + ]); + + // add people and groups to collection + await Promise.all([ + CollectionUser.create({ + collectionId: collection.id, + userId: alan.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + CollectionUser.create({ + collectionId: collection.id, + userId: bret.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + CollectionUser.create({ + collectionId: collection.id, + userId: ken.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + CollectionGroup.create({ + collectionId: collection.id, + groupId: group.id, + permission: CollectionPermission.ReadWrite, + createdById: user.id, + }), + ]); + + const res = await server.post("/api/documents.users", { + body: { + token: user.getJwtToken(), + id: document.id, + query: "Al", + }, + }); + const body = await res.json(); + + const anotherRes = await server.post("/api/documents.users", { + body: { + token: user.getJwtToken(), + id: document.id, + query: "e", + }, + }); + const anotherBody = await anotherRes.json(); + + expect(res.status).toBe(200); + expect(body.data.length).toBe(1); + expect(body.data[0].id).toContain(alan.id); + expect(body.data[0].name).toBe(alan.name); + + expect(anotherRes.status).toBe(200); + expect(anotherBody.data.length).toBe(3); + const memberIds = anotherBody.data.map((u: User) => u.id); + const memberNames = anotherBody.data.map((u: User) => u.name); + expect(memberIds).toContain(bret.id); + expect(memberIds).toContain(ken.id); + expect(memberIds).toContain(jamie.id); + expect(memberNames).toContain(bret.name); + expect(memberNames).toContain(ken.name); + expect(memberNames).toContain(jamie.name); + }); + + it("should not return suspended users", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const [alan, bret, ken] = await Promise.all([ + buildUser({ + name: "Alan Kay", + teamId: user.teamId, + }), + buildUser({ + name: "Bret Victor", + teamId: user.teamId, + }), + buildUser({ + name: "Ken Thompson", + teamId: user.teamId, + }), + ]); + + // add people to collection + await Promise.all([ + CollectionUser.create({ + collectionId: collection.id, + userId: alan.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + CollectionUser.create({ + collectionId: collection.id, + userId: bret.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + CollectionUser.create({ + collectionId: collection.id, + userId: ken.id, + permission: CollectionPermission.Read, + createdById: user.id, + }), + ]); + + // suspend Alan + alan.suspendedAt = new Date(); + await alan.save(); + + const res = await server.post("/api/documents.users", { + body: { + token: user.getJwtToken(), + id: document.id, + }, + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.length).toBe(2); + + const memberIds = body.data.map((u: User) => u.id); + expect(memberIds).not.toContain(alan.id); + expect(memberIds).toContain(bret.id); + expect(memberIds).toContain(ken.id); + }); +}); diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 0245e6d3c..75002d335 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -42,6 +42,7 @@ import { presentDocument, presentPolicies, presentPublicTeam, + presentUser, } from "@server/presenters"; import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; @@ -435,6 +436,59 @@ router.post( } ); +router.post( + "documents.users", + auth(), + pagination(), + validate(T.DocumentsUsersSchema), + async (ctx: APIContext) => { + const { id, query } = ctx.input.body; + const actor = ctx.state.auth.user; + const { offset, limit } = ctx.state.pagination; + const document = await Document.findByPk(id); + authorize(actor, "read", document); + + let users: User[] = []; + let total = 0; + + if (document.collectionId) { + const [collection, memberIds] = await Promise.all([ + Collection.findByPk(document.collectionId), + Collection.membershipUserIds(document.collectionId), + ]); + authorize(actor, "update", collection); + + let where: WhereOptions = { + id: { + [Op.in]: memberIds, + }, + suspendedAt: { + [Op.is]: null, + }, + }; + if (query) { + where = { + ...where, + name: { + [Op.iLike]: `%${query}%`, + }, + }; + } + + [users, total] = await Promise.all([ + User.findAll({ where, offset, limit }), + User.count({ where }), + ]); + } + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: users.map((user) => presentUser(user)), + policies: presentPolicies(actor, users), + }; + } +); + router.post( "documents.export", rateLimiter(RateLimiterStrategy.FivePerMinute), diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 2d87baaf6..656935af3 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -302,3 +302,12 @@ export const DocumentsCreateSchema = BaseSchema.extend({ }); export type DocumentsCreateReq = z.infer; + +export const DocumentsUsersSchema = BaseSchema.extend({ + body: BaseIdSchema.extend({ + /** Query term to search users by name */ + query: z.string().optional(), + }), +}); + +export type DocumentsUsersReq = z.infer;