Peterase commited on
Commit
5ebe979
Β·
1 Parent(s): 8b37702

feat: nested folder hierarchy with parent_id, root_only filter, recursive delete

Browse files
app/api/documents.py CHANGED
@@ -102,11 +102,17 @@ async def get_document_status(
102
  @router.get("/")
103
  async def list_documents(
104
  folder_id: Optional[str] = None,
 
105
  document_service: DocumentService = Depends(get_document_service),
106
  current_user: User = Depends(get_current_user),
107
  ):
108
- """List all documents."""
109
- documents = await document_service.list_documents(current_user, folder_id)
 
 
 
 
 
110
  return [_doc_response(doc) for doc in documents]
111
 
112
 
 
102
  @router.get("/")
103
  async def list_documents(
104
  folder_id: Optional[str] = None,
105
+ root_only: bool = False,
106
  document_service: DocumentService = Depends(get_document_service),
107
  current_user: User = Depends(get_current_user),
108
  ):
109
+ """
110
+ List documents.
111
+ - GET /documents/ β†’ all documents
112
+ - GET /documents/?folder_id=X β†’ documents in folder X
113
+ - GET /documents/?root_only=true β†’ only documents with no folder (root level)
114
+ """
115
+ documents = await document_service.list_documents(current_user, folder_id, root_only)
116
  return [_doc_response(doc) for doc in documents]
117
 
118
 
app/api/folders.py CHANGED
@@ -1,11 +1,11 @@
1
- """Folders API endpoints."""
2
  from fastapi import APIRouter, Depends, HTTPException, status
3
  from pydantic import BaseModel
4
  from sqlalchemy.orm import Session
5
  from app.database import get_db
6
  from app.auth import get_current_user
7
  from app.models import User, Folder, Document
8
- from typing import Optional
9
  import logging
10
 
11
  logger = logging.getLogger(__name__)
@@ -14,6 +14,7 @@ router = APIRouter(prefix="/folders", tags=["folders"])
14
 
15
  class CreateFolderRequest(BaseModel):
16
  name: str
 
17
 
18
 
19
  class RenameFolderRequest(BaseModel):
@@ -22,198 +23,303 @@ class RenameFolderRequest(BaseModel):
22
 
23
  class MoveDocumentRequest(BaseModel):
24
  document_id: str
25
- folder_id: Optional[str] = None # None = move to root
26
 
27
 
 
 
 
 
 
 
28
  @router.post("/", status_code=status.HTTP_201_CREATED)
29
  async def create_folder(
30
  request: CreateFolderRequest,
31
  db: Session = Depends(get_db),
32
- current_user: User = Depends(get_current_user)
33
  ):
34
- """Create a new folder."""
35
- # Check for duplicate name in same org
 
 
 
 
 
 
 
 
 
36
  existing = db.query(Folder).filter(
37
  Folder.org_id == current_user.org_id,
38
- Folder.name == request.name
 
39
  ).first()
40
  if existing:
41
  raise HTTPException(
42
  status_code=status.HTTP_400_BAD_REQUEST,
43
- detail=f"Folder '{request.name}' already exists"
44
  )
45
 
46
- folder = Folder(name=request.name, org_id=current_user.org_id)
 
 
 
 
47
  db.add(folder)
48
  db.commit()
49
  db.refresh(folder)
50
-
51
  return _folder_response(folder, db)
52
 
53
 
 
 
54
  @router.get("/")
55
  async def list_folders(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  db: Session = Depends(get_db),
57
- current_user: User = Depends(get_current_user)
58
  ):
59
- """List all folders with document counts."""
60
- folders = db.query(Folder).filter(
61
  Folder.org_id == current_user.org_id
62
  ).order_by(Folder.created_at.desc()).all()
63
 
64
- return [_folder_response(f, db) for f in folders]
 
 
 
 
 
 
 
 
 
 
65
 
66
 
 
 
67
  @router.get("/{folder_id}")
68
  async def get_folder(
69
  folder_id: str,
70
  db: Session = Depends(get_db),
71
- current_user: User = Depends(get_current_user)
72
  ):
73
- """Get a folder with its documents."""
74
- folder = _get_folder_or_404(folder_id, current_user.org_id, db)
 
 
 
 
 
75
 
76
  documents = db.query(Document).filter(
77
  Document.folder_id == folder_id,
78
- Document.org_id == current_user.org_id
79
  ).order_by(Document.created_at.desc()).all()
80
 
 
 
 
81
  return {
82
  **_folder_response(folder, db),
83
- "documents": [_doc_response(d) for d in documents]
 
 
84
  }
85
 
86
 
 
 
87
  @router.patch("/{folder_id}")
88
  async def rename_folder(
89
  folder_id: str,
90
  request: RenameFolderRequest,
91
  db: Session = Depends(get_db),
92
- current_user: User = Depends(get_current_user)
93
  ):
94
- """Rename a folder."""
95
- folder = _get_folder_or_404(folder_id, current_user.org_id, db)
96
 
97
- # Check duplicate name
98
  existing = db.query(Folder).filter(
99
  Folder.org_id == current_user.org_id,
100
- Folder.name == request.name,
101
- Folder.id != folder_id
 
102
  ).first()
103
  if existing:
104
- raise HTTPException(
105
- status_code=status.HTTP_400_BAD_REQUEST,
106
- detail=f"Folder '{request.name}' already exists"
107
- )
108
 
109
- folder.name = request.name
110
  db.commit()
111
  db.refresh(folder)
112
-
113
  return _folder_response(folder, db)
114
 
115
 
116
- @router.post("/{folder_id}/move-document")
117
- async def move_document_to_folder(
 
 
118
  folder_id: str,
119
- request: MoveDocumentRequest,
120
  db: Session = Depends(get_db),
121
- current_user: User = Depends(get_current_user)
122
  ):
123
- """Move a document into this folder (supports drag & drop)."""
124
- _get_folder_or_404(folder_id, current_user.org_id, db)
125
-
126
- document = db.query(Document).filter(
127
- Document.id == request.document_id,
128
- Document.org_id == current_user.org_id
129
- ).first()
130
- if not document:
131
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
132
-
133
- document.folder_id = folder_id
 
 
 
 
 
 
134
  db.commit()
 
 
135
 
136
- return {"message": "Document moved successfully", "document_id": request.document_id, "folder_id": folder_id}
137
 
 
138
 
139
- @router.delete("/{folder_id}/move-document")
140
- async def remove_document_from_folder(
141
  folder_id: str,
142
  request: MoveDocumentRequest,
143
  db: Session = Depends(get_db),
144
- current_user: User = Depends(get_current_user)
145
  ):
146
- """Remove a document from a folder (move to root)."""
147
- document = db.query(Document).filter(
148
  Document.id == request.document_id,
149
  Document.org_id == current_user.org_id,
150
- Document.folder_id == folder_id
151
  ).first()
152
- if not document:
153
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found in this folder")
154
-
155
- document.folder_id = None
156
  db.commit()
 
157
 
158
- return {"message": "Document removed from folder", "document_id": request.document_id}
159
 
 
160
 
161
  @router.delete("/{folder_id}")
162
  async def delete_folder(
163
  folder_id: str,
164
  force: bool = False,
165
  db: Session = Depends(get_db),
166
- current_user: User = Depends(get_current_user)
167
  ):
168
- """Delete a folder. Use ?force=true to also move documents to root."""
169
- folder = _get_folder_or_404(folder_id, current_user.org_id, db)
170
-
171
- doc_count = db.query(Document).filter(Document.folder_id == folder_id).count()
172
-
173
- if doc_count > 0 and not force:
174
- raise HTTPException(
175
- status_code=status.HTTP_400_BAD_REQUEST,
176
- detail=f"Folder has {doc_count} document(s). Use ?force=true to delete and move documents to root."
177
- )
178
 
179
  if force:
180
- # Move all documents to root
181
- db.query(Document).filter(Document.folder_id == folder_id).update({"folder_id": None})
 
 
 
 
 
 
 
 
 
182
 
183
- db.delete(folder)
184
- db.commit()
185
 
186
- return {"message": "Folder deleted successfully"}
187
 
 
188
 
189
- # ── helpers ──────────────────────────────────────────────────────────────────
190
-
191
- def _get_folder_or_404(folder_id: str, org_id: str, db: Session) -> Folder:
192
- folder = db.query(Folder).filter(
193
- Folder.id == folder_id,
194
- Folder.org_id == org_id
195
- ).first()
196
- if not folder:
197
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
198
- return folder
199
 
200
 
201
  def _folder_response(folder: Folder, db: Session) -> dict:
202
  doc_count = db.query(Document).filter(Document.folder_id == folder.id).count()
 
203
  return {
204
  "id": folder.id,
205
  "name": folder.name,
 
206
  "document_count": doc_count,
207
- "created_at": folder.created_at.isoformat()
 
208
  }
209
 
210
 
211
  def _doc_response(doc: Document) -> dict:
212
  return {
213
- "id": doc.id,
214
- "name": doc.name,
215
- "size": doc.size,
216
- "chunks": doc.chunks,
217
- "folder_id": doc.folder_id,
218
- "created_at": doc.created_at.isoformat()
219
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Folders API β€” full nested folder support (real file manager)."""
2
  from fastapi import APIRouter, Depends, HTTPException, status
3
  from pydantic import BaseModel
4
  from sqlalchemy.orm import Session
5
  from app.database import get_db
6
  from app.auth import get_current_user
7
  from app.models import User, Folder, Document
8
+ from typing import Optional, List
9
  import logging
10
 
11
  logger = logging.getLogger(__name__)
 
14
 
15
  class CreateFolderRequest(BaseModel):
16
  name: str
17
+ parent_id: Optional[str] = None # None = root level
18
 
19
 
20
  class RenameFolderRequest(BaseModel):
 
23
 
24
  class MoveDocumentRequest(BaseModel):
25
  document_id: str
 
26
 
27
 
28
+ class MoveFolderRequest(BaseModel):
29
+ parent_id: Optional[str] = None # None = move to root
30
+
31
+
32
+ # ── Create ────────────────────────────────────────────────────────────────────
33
+
34
  @router.post("/", status_code=status.HTTP_201_CREATED)
35
  async def create_folder(
36
  request: CreateFolderRequest,
37
  db: Session = Depends(get_db),
38
+ current_user: User = Depends(get_current_user),
39
  ):
40
+ """Create a folder. Pass parent_id to create a subfolder."""
41
+ # Validate parent exists and belongs to same org
42
+ if request.parent_id:
43
+ parent = db.query(Folder).filter(
44
+ Folder.id == request.parent_id,
45
+ Folder.org_id == current_user.org_id,
46
+ ).first()
47
+ if not parent:
48
+ raise HTTPException(status_code=404, detail="Parent folder not found")
49
+
50
+ # Duplicate name check within same parent
51
  existing = db.query(Folder).filter(
52
  Folder.org_id == current_user.org_id,
53
+ Folder.name == request.name.strip(),
54
+ Folder.parent_id == request.parent_id,
55
  ).first()
56
  if existing:
57
  raise HTTPException(
58
  status_code=status.HTTP_400_BAD_REQUEST,
59
+ detail=f"A folder named '{request.name}' already exists here",
60
  )
61
 
62
+ folder = Folder(
63
+ name=request.name.strip(),
64
+ org_id=current_user.org_id,
65
+ parent_id=request.parent_id,
66
+ )
67
  db.add(folder)
68
  db.commit()
69
  db.refresh(folder)
 
70
  return _folder_response(folder, db)
71
 
72
 
73
+ # ── List ──────────────────────────────────────────────────────────────────────
74
+
75
  @router.get("/")
76
  async def list_folders(
77
+ parent_id: Optional[str] = None, # None = root level; pass id for children
78
+ db: Session = Depends(get_db),
79
+ current_user: User = Depends(get_current_user),
80
+ ):
81
+ """
82
+ List folders.
83
+ - GET /folders/ β†’ root-level folders
84
+ - GET /folders/?parent_id=X β†’ subfolders of X
85
+ """
86
+ query = db.query(Folder).filter(
87
+ Folder.org_id == current_user.org_id,
88
+ Folder.parent_id == parent_id,
89
+ ).order_by(Folder.created_at.desc())
90
+
91
+ return [_folder_response(f, db) for f in query.all()]
92
+
93
+
94
+ @router.get("/tree")
95
+ async def folder_tree(
96
  db: Session = Depends(get_db),
97
+ current_user: User = Depends(get_current_user),
98
  ):
99
+ """Return the full folder tree (all folders with nesting info)."""
100
+ all_folders = db.query(Folder).filter(
101
  Folder.org_id == current_user.org_id
102
  ).order_by(Folder.created_at.desc()).all()
103
 
104
+ def build_tree(parent_id: Optional[str]) -> List[dict]:
105
+ nodes = [f for f in all_folders if f.parent_id == parent_id]
106
+ return [
107
+ {
108
+ **_folder_response(f, db),
109
+ "children": build_tree(f.id),
110
+ }
111
+ for f in nodes
112
+ ]
113
+
114
+ return build_tree(None)
115
 
116
 
117
+ # ── Get single ────────────────────────────────────────────────────────────────
118
+
119
  @router.get("/{folder_id}")
120
  async def get_folder(
121
  folder_id: str,
122
  db: Session = Depends(get_db),
123
+ current_user: User = Depends(get_current_user),
124
  ):
125
+ """Get folder details + its direct documents + subfolders."""
126
+ folder = _get_or_404(folder_id, current_user.org_id, db)
127
+
128
+ subfolders = db.query(Folder).filter(
129
+ Folder.parent_id == folder_id,
130
+ Folder.org_id == current_user.org_id,
131
+ ).order_by(Folder.created_at.desc()).all()
132
 
133
  documents = db.query(Document).filter(
134
  Document.folder_id == folder_id,
135
+ Document.org_id == current_user.org_id,
136
  ).order_by(Document.created_at.desc()).all()
137
 
138
+ # Build breadcrumb path
139
+ path = _build_path(folder, db)
140
+
141
  return {
142
  **_folder_response(folder, db),
143
+ "path": path,
144
+ "subfolders": [_folder_response(f, db) for f in subfolders],
145
+ "documents": [_doc_response(d) for d in documents],
146
  }
147
 
148
 
149
+ # ── Rename ────────────────────────────────────────────────────────────────────
150
+
151
  @router.patch("/{folder_id}")
152
  async def rename_folder(
153
  folder_id: str,
154
  request: RenameFolderRequest,
155
  db: Session = Depends(get_db),
156
+ current_user: User = Depends(get_current_user),
157
  ):
158
+ folder = _get_or_404(folder_id, current_user.org_id, db)
 
159
 
160
+ # Duplicate check within same parent
161
  existing = db.query(Folder).filter(
162
  Folder.org_id == current_user.org_id,
163
+ Folder.name == request.name.strip(),
164
+ Folder.parent_id == folder.parent_id,
165
+ Folder.id != folder_id,
166
  ).first()
167
  if existing:
168
+ raise HTTPException(400, detail=f"A folder named '{request.name}' already exists here")
 
 
 
169
 
170
+ folder.name = request.name.strip()
171
  db.commit()
172
  db.refresh(folder)
 
173
  return _folder_response(folder, db)
174
 
175
 
176
+ # ── Move folder ───────────────────────────────────────────────────────────────
177
+
178
+ @router.patch("/{folder_id}/move")
179
+ async def move_folder(
180
  folder_id: str,
181
+ request: MoveFolderRequest,
182
  db: Session = Depends(get_db),
183
+ current_user: User = Depends(get_current_user),
184
  ):
185
+ """Move a folder to a different parent (or root)."""
186
+ folder = _get_or_404(folder_id, current_user.org_id, db)
187
+
188
+ # Prevent moving into own descendant
189
+ if request.parent_id:
190
+ if request.parent_id == folder_id:
191
+ raise HTTPException(400, detail="Cannot move a folder into itself")
192
+ if _is_descendant(folder_id, request.parent_id, db):
193
+ raise HTTPException(400, detail="Cannot move a folder into its own subfolder")
194
+ parent = db.query(Folder).filter(
195
+ Folder.id == request.parent_id,
196
+ Folder.org_id == current_user.org_id,
197
+ ).first()
198
+ if not parent:
199
+ raise HTTPException(404, detail="Target folder not found")
200
+
201
+ folder.parent_id = request.parent_id
202
  db.commit()
203
+ db.refresh(folder)
204
+ return _folder_response(folder, db)
205
 
 
206
 
207
+ # ── Move document into folder ─────────────────────────────────────────────────
208
 
209
+ @router.post("/{folder_id}/move-document")
210
+ async def move_document_to_folder(
211
  folder_id: str,
212
  request: MoveDocumentRequest,
213
  db: Session = Depends(get_db),
214
+ current_user: User = Depends(get_current_user),
215
  ):
216
+ _get_or_404(folder_id, current_user.org_id, db)
217
+ doc = db.query(Document).filter(
218
  Document.id == request.document_id,
219
  Document.org_id == current_user.org_id,
 
220
  ).first()
221
+ if not doc:
222
+ raise HTTPException(404, detail="Document not found")
223
+ doc.folder_id = folder_id
 
224
  db.commit()
225
+ return {"message": "Document moved", "document_id": request.document_id, "folder_id": folder_id}
226
 
 
227
 
228
+ # ── Delete ────────────────────────────────────────────────────────────────────
229
 
230
  @router.delete("/{folder_id}")
231
  async def delete_folder(
232
  folder_id: str,
233
  force: bool = False,
234
  db: Session = Depends(get_db),
235
+ current_user: User = Depends(get_current_user),
236
  ):
237
+ """
238
+ Delete a folder.
239
+ - force=false (default): fails if folder has documents or subfolders
240
+ - force=true: recursively deletes all subfolders and moves documents to root
241
+ """
242
+ folder = _get_or_404(folder_id, current_user.org_id, db)
 
 
 
 
243
 
244
  if force:
245
+ _recursive_delete(folder_id, current_user.org_id, db)
246
+ else:
247
+ doc_count = db.query(Document).filter(Document.folder_id == folder_id).count()
248
+ sub_count = db.query(Folder).filter(Folder.parent_id == folder_id).count()
249
+ if doc_count > 0 or sub_count > 0:
250
+ raise HTTPException(
251
+ 400,
252
+ detail=f"Folder is not empty ({doc_count} files, {sub_count} subfolders). Use ?force=true."
253
+ )
254
+ db.delete(folder)
255
+ db.commit()
256
 
257
+ return {"message": "Folder deleted"}
 
258
 
 
259
 
260
+ # ── Helpers ───────────────────────────────────────────────────────────────────
261
 
262
+ def _get_or_404(folder_id: str, org_id: str, db: Session) -> Folder:
263
+ f = db.query(Folder).filter(Folder.id == folder_id, Folder.org_id == org_id).first()
264
+ if not f:
265
+ raise HTTPException(404, detail="Folder not found")
266
+ return f
 
 
 
 
 
267
 
268
 
269
  def _folder_response(folder: Folder, db: Session) -> dict:
270
  doc_count = db.query(Document).filter(Document.folder_id == folder.id).count()
271
+ sub_count = db.query(Folder).filter(Folder.parent_id == folder.id).count()
272
  return {
273
  "id": folder.id,
274
  "name": folder.name,
275
+ "parent_id": folder.parent_id,
276
  "document_count": doc_count,
277
+ "subfolder_count": sub_count,
278
+ "created_at": folder.created_at.isoformat(),
279
  }
280
 
281
 
282
  def _doc_response(doc: Document) -> dict:
283
  return {
284
+ "id": doc.id, "name": doc.name, "size": doc.size,
285
+ "chunks": doc.chunks, "status": doc.status,
286
+ "folder_id": doc.folder_id, "created_at": doc.created_at.isoformat(),
 
 
 
287
  }
288
+
289
+
290
+ def _build_path(folder: Folder, db: Session) -> List[dict]:
291
+ """Build breadcrumb path from root to this folder."""
292
+ path = []
293
+ current = folder
294
+ while current:
295
+ path.insert(0, {"id": current.id, "name": current.name})
296
+ if current.parent_id:
297
+ current = db.query(Folder).filter(Folder.id == current.parent_id).first()
298
+ else:
299
+ break
300
+ return path
301
+
302
+
303
+ def _is_descendant(ancestor_id: str, candidate_id: str, db: Session) -> bool:
304
+ """Check if candidate_id is a descendant of ancestor_id."""
305
+ current = db.query(Folder).filter(Folder.id == candidate_id).first()
306
+ while current and current.parent_id:
307
+ if current.parent_id == ancestor_id:
308
+ return True
309
+ current = db.query(Folder).filter(Folder.id == current.parent_id).first()
310
+ return False
311
+
312
+
313
+ def _recursive_delete(folder_id: str, org_id: str, db: Session) -> None:
314
+ """Recursively delete folder and all subfolders; move docs to root."""
315
+ # Move documents to root
316
+ db.query(Document).filter(Document.folder_id == folder_id).update({"folder_id": None})
317
+ # Recurse into subfolders
318
+ children = db.query(Folder).filter(Folder.parent_id == folder_id, Folder.org_id == org_id).all()
319
+ for child in children:
320
+ _recursive_delete(child.id, org_id, db)
321
+ # Delete this folder
322
+ folder = db.query(Folder).filter(Folder.id == folder_id).first()
323
+ if folder:
324
+ db.delete(folder)
325
+ db.commit()
app/application/document_service.py CHANGED
@@ -141,10 +141,20 @@ class DocumentService:
141
 
142
  # ── Other service methods ─────────────────────────────────────────────────
143
 
144
- async def list_documents(self, user: User, folder_id: Optional[str] = None) -> List[Document]:
 
 
 
 
 
145
  query = self.db.query(Document).filter(Document.org_id == user.org_id)
146
  if folder_id:
 
147
  query = query.filter(Document.folder_id == folder_id)
 
 
 
 
148
  return query.order_by(Document.created_at.desc()).all()
149
 
150
  async def get_document_status(self, document_id: str, user: User) -> Document:
 
141
 
142
  # ── Other service methods ─────────────────────────────────────────────────
143
 
144
+ async def list_documents(
145
+ self,
146
+ user: User,
147
+ folder_id: Optional[str] = None,
148
+ root_only: bool = False
149
+ ) -> List[Document]:
150
  query = self.db.query(Document).filter(Document.org_id == user.org_id)
151
  if folder_id:
152
+ # Specific folder
153
  query = query.filter(Document.folder_id == folder_id)
154
+ elif root_only:
155
+ # Only files with no folder (root level)
156
+ query = query.filter(Document.folder_id == None)
157
+ # else: no filter = all documents
158
  return query.order_by(Document.created_at.desc()).all()
159
 
160
  async def get_document_status(self, document_id: str, user: User) -> Document:
app/database.py CHANGED
@@ -59,6 +59,7 @@ def _run_migrations():
59
  for col_sql in [
60
  "ALTER TABLE documents ADD COLUMN IF NOT EXISTS status VARCHAR DEFAULT 'done'",
61
  "ALTER TABLE documents ADD COLUMN IF NOT EXISTS error_message TEXT",
 
62
  ]:
63
  try:
64
  conn.execute(__import__('sqlalchemy').text(col_sql))
 
59
  for col_sql in [
60
  "ALTER TABLE documents ADD COLUMN IF NOT EXISTS status VARCHAR DEFAULT 'done'",
61
  "ALTER TABLE documents ADD COLUMN IF NOT EXISTS error_message TEXT",
62
+ "ALTER TABLE folders ADD COLUMN IF NOT EXISTS parent_id VARCHAR REFERENCES folders(id)",
63
  ]:
64
  try:
65
  conn.execute(__import__('sqlalchemy').text(col_sql))
app/models.py CHANGED
@@ -31,9 +31,13 @@ class Folder(Base):
31
  id = Column(String, primary_key=True, default=generate_uuid)
32
  name = Column(String, nullable=False)
33
  org_id = Column(String, nullable=False, index=True)
 
 
34
  created_at = Column(DateTime, default=datetime.utcnow)
35
 
36
  documents = relationship("Document", back_populates="folder")
 
 
37
 
38
 
39
  class Document(Base):
 
31
  id = Column(String, primary_key=True, default=generate_uuid)
32
  name = Column(String, nullable=False)
33
  org_id = Column(String, nullable=False, index=True)
34
+ # parent_id = None means root-level folder
35
+ parent_id = Column(String, ForeignKey("folders.id"), nullable=True, index=True)
36
  created_at = Column(DateTime, default=datetime.utcnow)
37
 
38
  documents = relationship("Document", back_populates="folder")
39
+ # Self-referential: children folders
40
+ children = relationship("Folder", foreign_keys=[parent_id], lazy="select")
41
 
42
 
43
  class Document(Base):