9.2 KiB
date, title, status
| date | title | status |
|---|---|---|
| 2026-02-10 | Add Folder Support To V1 Api | ready |
Problem
The GET /api/v1/documents endpoint does not return documents inside folders. The underlying findDocuments() function defaults to folderId: null when no folderId is provided, meaning only root-level documents are returned. The V1 API never passes folderId, so folder documents are invisible to API consumers.
Additionally, neither the list endpoint nor the single-document endpoint exposes folderId in the response, so consumers cannot know which folder a document belongs to.
Root Cause
In packages/lib/server-only/document/find-documents.ts (line 222-226):
if (folderId !== undefined) {
whereClause.folderId = folderId;
} else {
whereClause.folderId = null; // Only root documents returned
}
The V1 getDocuments handler in packages/api/v1/implementation.ts (line 61-70) only passes page and perPage to findDocuments — it never extracts or forwards a folderId from the query string.
Decisions
These decisions were made during the spec interview:
- Fix V1 directly — The V1 API is deprecated but still actively used. This is a quick, low-risk fix. No need to defer to a newer API.
- Breaking change accepted — Returning ALL documents by default (instead of root-only) is intentional. The current root-only behavior is a bug, not a feature.
- No root-only query option needed — Not all documents are in folders, so consumers can filter client-side using the
folderIdfield in the response if needed. - No folder existence validation —
?folderId=nonexistentreturns empty array, not 404. Consistent with V1 list endpoint patterns. - Add
folderIdto both endpoints — BothGET /api/v1/documents(list) andGET /api/v1/documents/:id(single) will includefolderIdin the response. - Top-level
skipFolderFilteris sufficient — The inner helper filters (findDocumentsFilter,findTeamDocumentsFilter) receivefolderId: undefinedwhen skip is active. Prisma ignoresundefinedvalues in WHERE clauses, so these inner filters will not constrain by folder. No propagation needed. - Scope is minimal — Only
folderIdsupport. No other filters (status, period, query, senderIds) added in this change.
Scope
Three files need changes. No new files.
| File | Change |
|---|---|
packages/api/v1/schema.ts |
Add folderId to query schema + both response schemas |
packages/api/v1/implementation.ts |
Pass folderId through in getDocuments, add to getDocument response |
packages/lib/server-only/document/find-documents.ts |
Add skipFolderFilter option |
Changes
1. packages/api/v1/schema.ts — Add folderId to query + response schemas
Query schema (ZGetDocumentsQuerySchema, line 35-38):
export const ZGetDocumentsQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(10),
folderId: z
.string()
.describe(
'Filter documents by folder ID. When omitted, returns all documents regardless of folder.',
)
.optional(),
});
List response schema (ZSuccessfulDocumentResponseSchema, line 46-56):
Add folderId: z.string().nullish() so consumers can see which folder each document belongs to.
Single document response schema (ZSuccessfulGetDocumentResponseSchema, line 58-79):
Add folderId: z.string().nullish() to the extended schema as well.
2. packages/api/v1/implementation.ts — Pass folderId through + add to responses
getDocuments handler (line 61-70):
getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
const { data: documents, totalPages } = await findDocuments({
page,
perPage,
userId: user.id,
teamId: team.id,
folderId: args.query.folderId,
skipFolderFilter: args.query.folderId === undefined,
});
return {
status: 200,
body: {
documents: documents.map((document) => ({
id: mapSecondaryIdToDocumentId(document.secondaryId),
externalId: document.externalId,
userId: document.userId,
teamId: document.teamId,
folderId: document.folderId,
title: document.title,
status: document.status,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
completedAt: document.completedAt,
})),
totalPages,
},
};
}),
getDocument handler (line 91-197):
Add folderId: envelope.folderId to the response body mapping (alongside id, externalId, etc.).
3. packages/lib/server-only/document/find-documents.ts — Handle "return all" semantics
Add skipFolderFilter to the options type and modify the WHERE clause logic:
export type FindDocumentsOptions = {
// ... existing fields ...
folderId?: string;
skipFolderFilter?: boolean;
};
Modify the folderId logic (line 222-226):
if (!skipFolderFilter) {
if (folderId !== undefined) {
whereClause.folderId = folderId;
} else {
whereClause.folderId = null;
}
}
When skipFolderFilter is true:
- The top-level
whereClause.folderIdis never set — no folder constraint at the top level. - The inner helpers (
findDocumentsFilter,findTeamDocumentsFilter) receivefolderId: undefined, which Prisma ignores in WHERE objects — no folder constraint at the inner level either. - Result: all documents returned regardless of folder.
When skipFolderFilter is false (default, used by UI/tRPC callers):
- Existing behavior is completely unchanged.
folderId: undefinedstill defaults to root-only.
Why skipFolderFilter (Option B)
Two approaches were considered:
Option A: Change folderId: undefined semantics to mean "all documents"
- Risky: would affect all callers of
findDocuments(UI, tRPC) unless every caller is audited. - The UI intentionally shows root-only when no folder is selected.
Option B (chosen): Add skipFolderFilter boolean
- Additive — no existing callers pass this flag, so they're unaffected.
- Explicit — the intent is clear in the code.
- Safe — zero risk to UI/tRPC behavior.
Behavior Matrix
| Request | Current Behavior | New Behavior |
|---|---|---|
GET /api/v1/documents |
Root docs only | ALL docs (root + folders) |
GET /api/v1/documents?folderId=abc |
Not supported | Docs in folder abc only |
GET /api/v1/documents?folderId=nonexistent |
Not supported | Empty array, 200 OK |
GET /api/v1/documents/:id response |
No folderId field |
Includes folderId |
Implementation Notes
folderIdis aString?on theEnvelopemodel in Prisma, not a number.- The
findDocumentsfunction already acceptsfolderIdin its options type — it just needs theskipFolderFilterescape hatch. - No need to propagate
skipFolderFilterintofindDocumentsFilterorfindTeamDocumentsFilter. WhenfolderIdisundefined, those helpers embedfolderId: undefinedin their Prisma WHERE objects. Prisma stripsundefinedkeys, so no folder constraint is applied. This is well-documented Prisma behavior. - The
createDocumentendpoint already supportsfolderIdin the request body (line 139-144 of schema.ts), confirming the pattern. - The
getDocumenthandler fetches fromprisma.envelope.findFirstOrThrowwhich already includesfolderIdon the envelope — just needs to be added to the response mapping.
Testing
Manual and automated test cases:
GET /api/v1/documentsreturns docs from root AND subfolders.GET /api/v1/documents?folderId=<valid-id>returns only docs in that folder.GET /api/v1/documents?folderId=<nonexistent-id>returns empty array with 200 status.- List response includes
folderIdfield on each document (null for root docs, string for folder docs). GET /api/v1/documents/:idresponse includesfolderIdfield.- Existing UI/tRPC callers of
findDocumentsare unaffected (they don't passskipFolderFilter). - Pagination: verify
totalPagescorrectly reflects the larger result set when all docs are returned.
Breaking Change Notice
This is a breaking change for existing V1 API consumers:
- Before:
GET /api/v1/documentsreturned only root-level documents (those not in any folder). - After:
GET /api/v1/documentsreturns all documents regardless of folder placement.
Impact:
- Consumers paginating through results will see more documents in the total count.
- Consumers building UIs will now display folder documents they previously didn't see.
- The new
folderIdfield is additive and won't break existing response parsing.
This is considered a bug fix, not a feature removal. The previous behavior silently hid documents from API consumers.