Front Matter
Field | Value |
|---|---|
Title | Adventure Folder Structure - Full Implementation |
Author | scorevi |
Reviewers | zcrnnn, teruterubozuuu |
Created | April 21, 2026 |
Status | Approved |
References:
GitHub Issue: #62 — Adventure Folder Structure
Frontend sub-issues: #429, #430
Shared utility:
lib/utils/folder-helpers.tsRelated feature: Project Folder Structure (Issue #60)
Introduction & Goals
Problem Summary
WyzQuests creators can produce Adventures — multi-quest bundles that group related quests into a learner journey. As creators built more Adventures, the Adventures dashboard became an unsorted flat list with no organizational structure. Creators working for multiple clients or building themed curricula had no way to separate or group their work. This feature introduces a hierarchical folder system dedicated to the Adventures workspace, solving this organization problem for the Adventures content type.
Goals
Allow creators to organize their Adventures into named, nested folders.
Support full CRUD on folders: create, read, update (rename/move), delete (archive).
Support drag-and-drop reorder of folders within the same parent via a dedicated endpoint.
Support bulk-moving one or more adventures into a target folder.
Provide a full tree-view response for rendering the creator's folder hierarchy.
Provide full-text search across adventure folders and adventure titles with folder breadcrumb paths.
Detect and surface orphan folders (folders whose parent has been deleted outside the normal archive flow) for data integrity visibility.
Ensure folder reordering is atomic — either all
order_indexupdates succeed or none do.
Non-Goals
No UI implementation — this PR is backend-only. Frontend is tracked in issues #429–#430.
No hard deletion — folders are soft-deleted via archiving only.
No sharing between creators — folders are private; only agency admins get elevated read-only access.
Not the same as Project Folders — Adventure Folders exclusively organize Adventures in the Adventures workspace. The Project Folder system (Issue #60) organizes the Projects workspace (quests and adventures under a project context). These are two separate systems with separate tables.
No quest organization — this folder system is for Adventures only. Quests are organized under the Project Folder system.
Glossary
Term | Definition |
|---|---|
Adventure Folder | A named container owned by a creator that can hold Adventures and other Adventure Folders (nested). |
Root Folder | A folder with |
Tree View | A single-response nested representation of all the creator's adventure folders and their children. |
Breadcrumb | An ordered list of ancestor folder names used for navigation context (e.g., |
Sparse Indexing | An ordering strategy using gaps of 10,000 between |
Soft Delete / Archive | Setting |
Orphan Folder | A folder whose |
Unfiled Adventure | An adventure with |
Atomic Reorder | A PostgreSQL RPC function ( |


Technologies Used
Technology | Role |
|---|---|
Next.js 15 (App Router) | API route handlers |
TypeScript (Strict Mode) | Language; no |
Supabase (PostgreSQL) | Persistent data store with RLS |
PostgreSQL RPC |
|
Zod | Single source of truth for input validation and type inference |
Clerk | JWT-based authentication and identity management |
Jest | Schema validation testing |
Detailed Design & Implementation
Data Model / Schema
New Table: adventure_folders
Column | Type | Constraints | Notes |
|---|---|---|---|
|
| PK, | |
|
| NOT NULL, FK → | Supabase UUID, mapped from Clerk ID |
|
| NOT NULL, | |
|
| nullable, | |
|
| nullable, FK → | Self-reference; NULL = root |
|
| NOT NULL DEFAULT 0 | Sparse gaps of 10,000 |
|
| NOT NULL DEFAULT FALSE | Soft delete flag |
|
| nullable | Set when |
|
| NOT NULL DEFAULT NOW() | |
|
| NOT NULL DEFAULT NOW() | Auto-updated via trigger |
Difference from Project Folders: Adventure Folders do not have an
is_expandedcolumn. UI expansion state for the Adventures workspace is not persisted server-side.
Modified Table: adventures
Column Added | Type | Notes |
|---|---|---|
|
| NULL = unfiled |
Indexes
Index Name | Table | Columns | Purpose |
|---|---|---|---|
|
|
| Fast owner lookup |
|
|
| Fast child-folder lookup |
|
|
| Fast folder content lookup |
|
|
| Sorted folder listings |
RLS Policies
Policy | Operation | Rule |
|---|---|---|
| SELECT |
|
| SELECT | Agency OWNER/ADMIN can read team members' folders |
| INSERT |
|
| UPDATE |
|
| DELETE |
|
PostgreSQL RPC: reorder_adventure_folders
File: lib/supabase/migrations/20260427_add_reorder_adventure_folders_rpc.sql
Added in response to PR #436 blocking feedback (non-atomic row-by-row reorder). The function accepts an array of {id, order_index} objects and updates them all inside a single PostgreSQL transaction. If any row fails (not found or wrong owner), the entire transaction rolls back.
reorder_adventure_folders( p_creator_id UUID, p_folder_orders JSONB -- [{id: "...", order_index: 10000}, ...]) RETURNS INTEGER -- count of rows updated
Zod Schemas (lib/schemas/adventure-folder.schema.ts)
Schema | Purpose |
|---|---|
| Validates |
| Partial update; adds |
| Move folder: |
| Bulk content move: |
| Archive: |
| Query params: |
| Drag-drop reorder: |
| Search: |
| Response shape: |
| Shared: |
API Specification
Base path: /api/creator/adventure-folders
Authentication: All endpoints require a valid Clerk JWT (authenticateUserWithRole()).
Response format: All responses use the ApiResponse wrapper:
{ "success": true, "data": { ... }, "error": null }{ "success": false, "data": null, "error": { "code": "VALIDATION_ERROR", "message": "..." } }
Cache-Control header (all GET responses): private, max-age=30, stale-while-revalidate=60
GET /api/creator/adventure-folders
Lists adventure folders for the authenticated creator.
Query Parameters:
Param | Type | Default | Description |
|---|---|---|---|
|
|
| Filter by parent (null = root) |
|
|
| Include archived folders |
|
|
| Attach adventure and child-folder counts per folder |
|
|
| Return full nested tree instead of flat list |
Response (flat list): Array of AdventureFolderResponse objects, each with adventureCount and childFolderCount computed fields.
Response (tree view): The roots array of AdventureFolderTreeNode is returned. Each node may carry isOrphan: true if its parent_folder_id references a missing row (orphan detection, added PR #436). The full tree response also exposes orphanCount for monitoring purposes.
POST /api/creator/adventure-folders
Creates a new adventure folder.
Request Body:
{ "name": "Fantasy Adventures", "description": "All fantasy-themed adventures", "parentFolderId": "550e8400-e29b-41d4-a716-446655440000"}
Behavior:
Validates body with
createAdventureFolderSchema.If
parentFolderIdprovided, verifies it exists and belongs to the creator.Calculates
order_indexas(max existing order_index at parent level) + 10,000.Inserts the folder row.
GET /api/creator/adventure-folders/[folderId]
Returns a single folder with its breadcrumb path, child folders, and contained adventures.
Performance (PR #436 optimization): Fetches all of the creator's folders in a single query, then builds the breadcrumb trail via in-memory map traversal (Map<folderId, folder>). This replaces the previous approach of one Supabase query per ancestor (N+1 problem).
Response:
{ "meta": { "id": "...", "name": "Beginner Series", "breadcrumbs": [ { "id": "...", "name": "Fantasy" } ], "adventureCount": 4, "childFolderCount": 1, ... }, "content": { "advList": [...] }, "folders": [...]}
PUT /api/creator/adventure-folders/[folderId]
Updates a folder's metadata (name, description) or moves it to a new parent.
Move validation:
Checks the new depth would not exceed
MAX_FOLDER_DEPTH = 20.Calls
checkIsDescendant()to prevent circular references.
DELETE /api/creator/adventure-folders/[folderId]
Archives a folder (soft delete). When archiveContents: true, recursively archives all child folders and sets adventure_folder_id = NULL on all contained adventures (unfiling them).
PATCH /api/creator/adventure-folders/reorder
Atomically reorders folders within the same parent level using the PostgreSQL RPC.
Request Body:
{ "parentFolderId": "...", "orderedFolderIds": ["id-1", "id-2", "id-3"]}
Behavior:
Validates body with
reorderAdventureFoldersSchema.Calls the
reorder_adventure_folders(userId, [{id, order_index}])RPC with values(position + 1) * 10,000.If the RPC raises an exception (any folder not found or wrong owner), the entire transaction rolls back — no partial state.
Key difference from Project Folders: This endpoint uses a PostgreSQL RPC for true atomicity. The project folder reorder uses application-level rollback logic.
POST /api/creator/adventure-folders/move-adventures
Bulk-moves one or more adventures to a target folder (or to root if targetFolderId: null).
Request Body:
{ "contentId": ["adventure-uuid-1", "adventure-uuid-2"], "targetFolderId": "folder-uuid"}
Deduplication: Adventures already in the target folder are filtered out before the update; the response reports only the count of items actually moved.
Count reliability (PR #436 fix): Moved count is computed as adventureIds.length instead of the Supabase count property, which was found to be unreliable.
GET /api/creator/adventure-folders/search
Searches adventure folders and adventure titles matching a query string.
Query Parameters:
Param | Type | Description |
|---|---|---|
|
| Search term |
|
| Attach breadcrumb paths to results |
|
| Filter by status |
Security: User input is run through escapeForPostgrestFilter() before being passed to Supabase .ilike() filters, preventing PostgREST grammar injection and LIKE wildcard attacks.
Behavior: Matching folder names appear in results with isFolder: true (Google Drive-style). Adventure content is searched by title and description.
Logic & WorkflowsTree Building with Orphan Detection (buildAdventureFolderTree)
Fetch all of the creator's folders in one query.
Build a
Map<folderId, node>for O(1) lookups.Iterate all folders:
If
parent_folder_idis set but the parent is not in the map → mark nodeisOrphan: trueand add to roots.Otherwise, attach as a child of the parent node.
Collect nodes with
parent_folder_id = NULLas the normal roots.Increment
orphanCountfor each orphan detected.Return
{ roots: AdventureFolderTreeNode[], orphanCount }.
Only
rootsis returned in the API response for consistency.orphanCountis retained for potential future monitoring endpoints.
Rather than issuing one Supabase query per ancestor:
Fetch all of the creator's folders in a single query (reused from the main fetch).
Build a
Map<folderId, folder>for O(1) parent access.Walk
parent_folder_idreferences in-memory, prepending each ancestor to the breadcrumb array.Stop at
MAX_FOLDER_DEPTH = 20to guard against cycles in corrupted data.
Circular Reference Prevention
When moving a folder (PUT /[folderId] with newParentFolderId):
checkIsDescendant("adventure_folders", folderId, newParentFolderId, userId)is called.Recursively walks the subtree rooted at
folderIdlooking fornewParentFolderId.If found, the move is rejected with
400 Bad Request.Depth is capped at
MAX_FOLDER_DEPTH = 20.
Sparse Index Reordering
order_indexvalues use gaps of 10,000.Insertions between existing items can use the midpoint without renumbering.
PATCH /reorderre-assigns(position + 1) * 10,000for a full reorder via the atomic RPC.
Infrastructure & Operations
Dependencies
Dependency | Type | Notes |
|---|---|---|
Supabase PostgreSQL | Database | Requires |
Clerk | Auth | JWT must include |
| Internal | Maps Clerk |
| Internal | Modified with |
| Shared utility |
|
Monitoring & Alerting
Error visibility: All database errors surface via
ApiResponseHelper.internalError()(HTTP 500). These are logged by Supabase's built-in logging dashboard.RLS failures: A Supabase RLS denial returns
nulldata, causing the route to return404 Not Found. These appear as unexpected 404s in API analytics and should be investigated if frequent.Orphan detection: The tree-view response includes an
orphanCountfield when non-zero. A non-zero count indicates a data integrity issue (e.g., a parent folder was hard-deleted or itsidwas changed outside the normal archive flow).Metrics to watch:
Rate of 500 errors on
/api/creator/adventure-foldersroutes.treeView=truequery performance (scales with creator's folder count).Non-zero
orphanCountvalues in tree responses.
Deployment Plan
Two database migrations must be run in order before the API routes go live:
Step | Migration File | Description |
|---|---|---|
1 |
| Creates |
2 |
| Creates |
No feature flag required — the API is additive. Existing adventures have adventure_folder_id = NULL (unfiled) until moved.
Testing & Quality Assurance
Test Strategy
File: tests/api/adventure-folders.test.ts
Test coverage note (PR #436): These are schema-level validation tests only. They do not exercise actual route handlers or database operations. See Known Limitations for the integration test gap.
Area | Tests |
|---|---|
| Valid input, nested parent, empty name rejection, name > 100 chars rejection, description > 500 chars rejection, invalid UUID format, whitespace trimming |
| Partial fields, missing |
| Single and bulk IDs, empty array rejection, invalid UUID in array |
| Valid order, empty |
| Valid query, empty query rejection, query > 200 chars rejection, |
| Default values, |
|
|
| Full response shape conformance including |
Known Limitations
Schema-only tests — no integration tests for route handlers or actual database operations. A test database fixture with mock Supabase calls or a real test schema is needed to cover route-level logic.
No
is_expandedcolumn — unlike Project Folders, the Adventures folder tree UI state is not persisted server-side. If this is needed in future, a migration to add this column would be required.Breadcrumb optimization is single-folder GET only — the tree-view response does not embed per-node breadcrumbs. Client code must derive breadcrumbs from the tree structure itself.
RPC grant scope — the
reorder_adventure_foldersfunction usesSECURITY DEFINERand is granted to theauthenticatedrole. While the function enforcescreator_idownership, the broad role grant should be reviewed if the function is ever expanded.
Maintenance & Support
Troubleshooting
Symptom: Creator's folder tree shows isOrphan: true nodes
A folder's
parent_folder_idreferences a row that no longer exists.Check for manual DB interventions or failed archive cascades:
SELECT id, name, parent_folder_idFROM adventure_foldersWHERE parent_folder_id IS NOT NULLAND parent_folder_id NOT IN (SELECT id FROM adventure_folders)AND creator_id = '<creator_uuid>';Resolution: Set
parent_folder_id = NULLon orphaned rows to promote them to root, or re-create their missing parent.
Symptom: 400 Bad Request — "All folders must have the same parent folder" on reorder
The
orderedFolderIdsarray contains folders from different parent levels.The client-side drag-and-drop UI should constrain reordering to siblings only. If this error occurs in production, check for a client-side bug where cross-parent drag is permitted.
Symptom: Reorder returns a 500 error after partial update
This should not occur since the atomic RPC rolls back the entire transaction on failure.
If it does, check the Supabase function logs for the
reorder_adventure_foldersRPC. Look for the exception message:"Folder <id> not found or not owned by creator".
Symptom: Creator sees an empty folder after moving adventures in
Verify the
adventure_folder_idcolumn was updated:SELECT id, adventure_folder_id FROM adventuresWHERE id IN ('<adventure-uuid-1>', '<adventure-uuid-2>');If the column is still
NULL, check if the move-adventures request returned amoved: 0count (deduplication may have skipped them if they were already in the target folder).
Symptom: 404 when accessing a valid folder
The folder exists but the
creator_idin the RLS policy doesn't match the JWTsubclaim.Run:
SELECT id, creator_id FROM adventure_folders WHERE id = '<folder-uuid>';SELECT id, clerk_id FROM app_users WHERE clerk_id = '<clerk-sub-claim>';and verify that
app_users.idmatchesadventure_folders.creator_id.
Symptom: Reorder not persisting after page refresh
Check
order_indexvalues in the database for the affected folders.Confirm the
PATCH /reorderresponse returnedsuccess: true.Verify the RPC function exists in Supabase:
SELECT proname FROM pg_proc WHERE proname = 'reorder_adventure_folders';
Changelog
Version | Status | Description | Date |
|---|---|---|---|
1.0 | Draft | Initial backend implementation: | 04/21/2026 |
1.1 | Approved | PR #436 feedback applied: (1) Fixed non-atomic reorder — added | 04/27/2026 |
Document version:
1.0 — Draft, Initial backend implementation pushed to develop after initial dev review, 04/21/2026
1.1 — Approved, PR #436 blocking issues resolved and non-blocking improvements applied, 04/27/2026