diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index 1c35680930..ca364fb2f7 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -707,6 +707,49 @@ describe("#documents.list", () => { expect(body.data.length).toEqual(0); }); + it("should return draft documents when filtering with statusFilter", async () => { + const user = await buildUser(); + const document = await buildDraftDocument({ + userId: user.id, + teamId: user.teamId, + }); + const res = await server.post("/api/documents.list", user, { + body: { + statusFilter: [StatusFilter.Draft], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + expect(body.pagination.total).toEqual(1); + }); + + it("should return drafts shared directly with the user", async () => { + const user = await buildUser(); + const author = await buildUser({ teamId: user.teamId }); + const document = await buildDraftDocument({ + userId: author.id, + teamId: user.teamId, + }); + await UserMembership.create({ + documentId: document.id, + userId: user.id, + createdById: author.id, + permission: DocumentPermission.Read, + }); + const res = await server.post("/api/documents.list", user, { + body: { + statusFilter: [StatusFilter.Draft], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + expect(body.pagination.total).toEqual(1); + }); + it("should not return archived documents", async () => { const user = await buildUser(); const document = await buildDocument({ diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 6928460dec..62b8eeeccd 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -243,6 +243,19 @@ router.post( } if (statusFilter?.includes(StatusFilter.Draft)) { + // Pre-fetch document IDs the user has a direct membership on so the + // filter can be expressed without referencing the (separately-loaded) + // memberships association, which would otherwise break the COUNT query. + const membershipDocumentIds = ( + await UserMembership.findAll({ + attributes: ["documentId"], + where: { + userId: user.id, + documentId: { [Op.ne]: null }, + }, + }) + ).map((m) => m.documentId as string); + statusQuery.push({ [Op.and]: [ { @@ -255,7 +268,7 @@ router.post( [Op.or]: [ // Only ever include draft results for the user's own documents { createdById: user.id }, - { "$memberships.id$": { [Op.ne]: null } }, + { id: membershipDocumentIds }, ], }, ], @@ -292,12 +305,24 @@ router.post( : undefined : [[sort, direction]]; + const includeDrafts = !!statusFilter?.includes(StatusFilter.Draft); + + // The withDrafts scope drops the defaultScope filters, so re-apply the + // ones we still want — templates and trial-import documents should never + // appear in this listing. + if (includeDrafts) { + where[Op.and].push({ + template: false, + sourceMetadata: { trial: { [Op.is]: null } }, + }); + } + // When sorting by index, pagination is already handled by slicing documentIds, // so we skip the SQL-level offset to avoid double-pagination const { results: documents, pagination } = await paginateQuery( ctx, ({ offset: queryOffset, limit: queryLimit }) => - Document.withMembershipScope(user.id).findAll({ + Document.withMembershipScope(user.id, { includeDrafts }).findAll({ where, order: orderClause as Order, offset: sort === "index" ? 0 : queryOffset, @@ -306,7 +331,10 @@ router.post( documentIds, }, }), - () => Document.count({ where }) + () => + Document.withMembershipScope(user.id, { includeDrafts }).count({ + where, + }) ); const data = await presentDocuments(ctx, documents);