|
| 1 | +"""Service + route for the collection-items delta endpoint. |
| 2 | +
|
| 3 | +POST /api-client/collections/{collection_id}/items:apply-delta |
| 4 | +
|
| 5 | +Applies a list of ops (add / update / delete / move) to a collection's items |
| 6 | +tree in-memory, then persists the result in a single atomic update_one call. |
| 7 | +Cache is invalidated via bump_version exactly as the existing patch_collection |
| 8 | +service does. |
| 9 | +""" |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +from typing import Any |
| 13 | + |
| 14 | +from bson import ObjectId |
| 15 | +from bson.errors import InvalidId |
| 16 | +from fastapi import APIRouter, Depends, HTTPException, status |
| 17 | +from pymongo import ReturnDocument |
| 18 | +from pymongo.errors import PyMongoError |
| 19 | + |
| 20 | +from app.api.routes.auth.services import get_current_uid |
| 21 | +from app.api.routes.api_client.schema import ( |
| 22 | + AddItemOp, |
| 23 | + ApplyDeltaRequest, |
| 24 | + ApplyDeltaResponse, |
| 25 | + ApiClientCollectionOut, |
| 26 | + DeleteItemOp, |
| 27 | + MoveItemOp, |
| 28 | + Op, |
| 29 | + UpdateItemOp, |
| 30 | +) |
| 31 | +from app.core.cache import bump_version |
| 32 | +from app.database import db_manager |
| 33 | +from app.utils.collection_name import API_CLIENT_COLLECTIONS |
| 34 | + |
| 35 | +router = APIRouter() |
| 36 | + |
| 37 | + |
| 38 | +def _parse_oid(raw: str, *, kind: str) -> ObjectId: |
| 39 | + try: |
| 40 | + return ObjectId(raw) |
| 41 | + except InvalidId as exc: |
| 42 | + raise HTTPException( |
| 43 | + status_code=status.HTTP_400_BAD_REQUEST, |
| 44 | + detail=f"Invalid {kind} id.", |
| 45 | + ) from exc |
| 46 | + |
| 47 | + |
| 48 | +def _collection_to_out(doc: dict[str, Any]) -> ApiClientCollectionOut: |
| 49 | + oid = doc.get("_id") |
| 50 | + return ApiClientCollectionOut( |
| 51 | + id=str(oid) if oid is not None else "", |
| 52 | + name=doc.get("name", ""), |
| 53 | + items=list(doc.get("items") or []), |
| 54 | + ) |
| 55 | + |
| 56 | + |
| 57 | +# ── In-memory tree walkers ──────────────────────────────────────────────────── |
| 58 | + |
| 59 | +def _apply_add( |
| 60 | + items: list[dict[str, Any]], |
| 61 | + parent_id: str, |
| 62 | + collection_id: str, |
| 63 | + new_item: dict[str, Any], |
| 64 | + position: int | None, |
| 65 | +) -> list[dict[str, Any]]: |
| 66 | + """Insert new_item under parent_id. parent_id may be the collection root.""" |
| 67 | + if parent_id == collection_id: |
| 68 | + # Insert at root level |
| 69 | + if position is None or position >= len(items): |
| 70 | + return items + [new_item] |
| 71 | + result = list(items) |
| 72 | + result.insert(position, new_item) |
| 73 | + return result |
| 74 | + |
| 75 | + return _add_in_children(items, parent_id, new_item, position) |
| 76 | + |
| 77 | + |
| 78 | +def _add_in_children( |
| 79 | + items: list[dict[str, Any]], |
| 80 | + parent_id: str, |
| 81 | + new_item: dict[str, Any], |
| 82 | + position: int | None, |
| 83 | +) -> list[dict[str, Any]]: |
| 84 | + result = [] |
| 85 | + for item in items: |
| 86 | + if item.get("id") == parent_id and item.get("type") == "folder": |
| 87 | + children = list(item.get("items") or []) |
| 88 | + if position is None or position >= len(children): |
| 89 | + children = children + [new_item] |
| 90 | + else: |
| 91 | + children.insert(position, new_item) |
| 92 | + result.append({**item, "items": children}) |
| 93 | + elif item.get("type") == "folder": |
| 94 | + result.append({**item, "items": _add_in_children(item.get("items") or [], parent_id, new_item, position)}) |
| 95 | + else: |
| 96 | + result.append(item) |
| 97 | + return result |
| 98 | + |
| 99 | + |
| 100 | +def _apply_update( |
| 101 | + items: list[dict[str, Any]], |
| 102 | + item_id: str, |
| 103 | + patch: dict[str, Any], |
| 104 | +) -> tuple[list[dict[str, Any]], bool]: |
| 105 | + """Recursively apply patch to the item with item_id. Returns (new_items, found).""" |
| 106 | + result = [] |
| 107 | + found = False |
| 108 | + for item in items: |
| 109 | + if item.get("id") == item_id: |
| 110 | + result.append({**item, **patch}) |
| 111 | + found = True |
| 112 | + elif item.get("type") == "folder": |
| 113 | + new_children, child_found = _apply_update(item.get("items") or [], item_id, patch) |
| 114 | + result.append({**item, "items": new_children}) |
| 115 | + if child_found: |
| 116 | + found = True |
| 117 | + else: |
| 118 | + result.append(item) |
| 119 | + return result, found |
| 120 | + |
| 121 | + |
| 122 | +def _apply_delete( |
| 123 | + items: list[dict[str, Any]], |
| 124 | + item_id: str, |
| 125 | +) -> tuple[list[dict[str, Any]], bool]: |
| 126 | + """Recursively remove item_id. Returns (new_items, found).""" |
| 127 | + new_items = [] |
| 128 | + found = False |
| 129 | + for item in items: |
| 130 | + if item.get("id") == item_id: |
| 131 | + found = True |
| 132 | + # skip (delete) |
| 133 | + elif item.get("type") == "folder": |
| 134 | + new_children, child_found = _apply_delete(item.get("items") or [], item_id) |
| 135 | + new_items.append({**item, "items": new_children}) |
| 136 | + if child_found: |
| 137 | + found = True |
| 138 | + else: |
| 139 | + new_items.append(item) |
| 140 | + return new_items, found |
| 141 | + |
| 142 | + |
| 143 | +def _extract_item( |
| 144 | + items: list[dict[str, Any]], |
| 145 | + item_id: str, |
| 146 | +) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: |
| 147 | + """Remove and return item_id from the tree.""" |
| 148 | + new_items = [] |
| 149 | + extracted: dict[str, Any] | None = None |
| 150 | + for item in items: |
| 151 | + if item.get("id") == item_id: |
| 152 | + extracted = item |
| 153 | + elif item.get("type") == "folder": |
| 154 | + new_children, child_extracted = _extract_item(item.get("items") or [], item_id) |
| 155 | + new_items.append({**item, "items": new_children}) |
| 156 | + if child_extracted is not None: |
| 157 | + extracted = child_extracted |
| 158 | + else: |
| 159 | + new_items.append(item) |
| 160 | + return new_items, extracted |
| 161 | + |
| 162 | + |
| 163 | +def _id_exists_in_tree(items: list[dict[str, Any]], node_id: str) -> bool: |
| 164 | + """Return True if node_id appears anywhere in the tree (any level).""" |
| 165 | + for item in items: |
| 166 | + if item.get("id") == node_id: |
| 167 | + return True |
| 168 | + if item.get("type") == "folder": |
| 169 | + if _id_exists_in_tree(item.get("items") or [], node_id): |
| 170 | + return True |
| 171 | + return False |
| 172 | + |
| 173 | + |
| 174 | +def _apply_move( |
| 175 | + items: list[dict[str, Any]], |
| 176 | + item_id: str, |
| 177 | + new_parent_id: str, |
| 178 | + new_index: int, |
| 179 | + collection_id: str, |
| 180 | +) -> tuple[list[dict[str, Any]], bool]: |
| 181 | + """Move item_id to new_parent_id at new_index. Returns (new_items, success).""" |
| 182 | + # Validate that new_parent_id exists (root or a folder in the tree). |
| 183 | + if new_parent_id != collection_id and not _id_exists_in_tree(items, new_parent_id): |
| 184 | + raise HTTPException( |
| 185 | + status_code=status.HTTP_400_BAD_REQUEST, |
| 186 | + detail="move: new_parent_id not found", |
| 187 | + ) |
| 188 | + |
| 189 | + # Step 1: extract the item |
| 190 | + items_without, target = _extract_item(items, item_id) |
| 191 | + if target is None: |
| 192 | + return items, False |
| 193 | + |
| 194 | + # Step 2: insert at new parent |
| 195 | + new_items = _apply_add(items_without, new_parent_id, collection_id, target, new_index) |
| 196 | + return new_items, True |
| 197 | + |
| 198 | + |
| 199 | +# ── Service ─────────────────────────────────────────────────────────────────── |
| 200 | + |
| 201 | +async def apply_collection_delta( |
| 202 | + uid: str, |
| 203 | + collection_id: str, |
| 204 | + ops: list[Op], |
| 205 | +) -> ApiClientCollectionOut: |
| 206 | + oid = _parse_oid(collection_id, kind="collection") |
| 207 | + |
| 208 | + # Fetch + ownership check BEFORE any mutation |
| 209 | + doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid}) |
| 210 | + if not doc: |
| 211 | + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.") |
| 212 | + |
| 213 | + items: list[dict[str, Any]] = list(doc.get("items") or []) |
| 214 | + |
| 215 | + # Apply all ops in-memory (atomic within a single request) |
| 216 | + for op in ops: |
| 217 | + if isinstance(op, AddItemOp): |
| 218 | + items = _apply_add(items, op.parent_id, collection_id, op.item, op.position) |
| 219 | + |
| 220 | + elif isinstance(op, UpdateItemOp): |
| 221 | + items, found = _apply_update(items, op.item_id, op.patch) |
| 222 | + if not found: |
| 223 | + raise HTTPException( |
| 224 | + status_code=status.HTTP_404_NOT_FOUND, |
| 225 | + detail=f"Item {op.item_id!r} not found.", |
| 226 | + ) |
| 227 | + |
| 228 | + elif isinstance(op, DeleteItemOp): |
| 229 | + items, found = _apply_delete(items, op.item_id) |
| 230 | + if not found: |
| 231 | + raise HTTPException( |
| 232 | + status_code=status.HTTP_404_NOT_FOUND, |
| 233 | + detail=f"Item {op.item_id!r} not found.", |
| 234 | + ) |
| 235 | + |
| 236 | + elif isinstance(op, MoveItemOp): |
| 237 | + items, ok = _apply_move(items, op.item_id, op.new_parent_id, op.new_index, collection_id) |
| 238 | + if not ok: |
| 239 | + raise HTTPException( |
| 240 | + status_code=status.HTTP_404_NOT_FOUND, |
| 241 | + detail=f"Item {op.item_id!r} not found.", |
| 242 | + ) |
| 243 | + |
| 244 | + # Single atomic update |
| 245 | + try: |
| 246 | + updated_doc = await db_manager.find_one_and_update( |
| 247 | + API_CLIENT_COLLECTIONS, |
| 248 | + {"_id": oid, "created_by": uid}, |
| 249 | + {"$set": {"items": items}}, |
| 250 | + return_document=ReturnDocument.AFTER, |
| 251 | + ) |
| 252 | + except PyMongoError as exc: |
| 253 | + raise HTTPException( |
| 254 | + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| 255 | + detail="Failed to update collection.", |
| 256 | + ) from exc |
| 257 | + |
| 258 | + if not updated_doc: |
| 259 | + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.") |
| 260 | + |
| 261 | + # Invalidate cache (same namespace as patch_collection / delete_collection) |
| 262 | + await bump_version(ns="api_client", uid=uid) |
| 263 | + |
| 264 | + return _collection_to_out(updated_doc) |
| 265 | + |
| 266 | + |
| 267 | +# ── Route ───────────────────────────────────────────────────────────────────── |
| 268 | + |
| 269 | +@router.post( |
| 270 | + "/collections/{collection_id}/items:apply-delta", |
| 271 | + response_model=ApplyDeltaResponse, |
| 272 | + summary="Apply delta ops to a collection's items tree", |
| 273 | +) |
| 274 | +async def apply_delta( |
| 275 | + collection_id: str, |
| 276 | + body: ApplyDeltaRequest, |
| 277 | + uid: str = Depends(get_current_uid), |
| 278 | +) -> ApplyDeltaResponse: |
| 279 | + collection = await apply_collection_delta(uid, collection_id, body.ops) |
| 280 | + return ApplyDeltaResponse(collection=collection) |
0 commit comments