From f9dc1a3983caccbfb2469516557afa3f7603c497 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 25 May 2026 17:02:46 -0400 Subject: [PATCH] fix: documents.list with Draft status filter throws database error (#12426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: documents.list with Draft status filter throws database error The count() query referenced $memberships.id$ in WHERE but had no membership include, causing "missing FROM-clause entry for table memberships". The findAll path was also silently dropping drafts because withMembershipScope defaulted to defaultScope (which filters publishedAt != null). Pre-fetch the user's UserMembership document IDs and filter by id IN (...) on both find and count, and pass includeDrafts: true when the Draft filter is active. * Preserve template/trial filters when including drafts * Move template/trial filters into withDrafts scope * Revert withDrafts scope filters, apply at call site instead Adding template/trial filters to withDrafts broke includes in places like Share's withCollectionPermissions where the document include must remain optional (LEFT JOIN) — adding a where promoted it to INNER JOIN and dropped shares without a documentId. --- server/routes/api/documents/documents.test.ts | 43 +++++++++++++++++++ server/routes/api/documents/documents.ts | 34 +++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) 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);