|
| 1 | +"""Entity business logic and Supabase CRUD operations. |
| 2 | +
|
| 3 | +Provides service-layer functions for entity lifecycle management. |
| 4 | +All functions accept a supabase Client as the first argument; |
| 5 | +dependency injection happens at the route handler level. |
| 6 | +""" |
| 7 | + |
| 8 | +from postgrest.exceptions import APIError |
| 9 | +from supabase import Client |
| 10 | + |
| 11 | +from app.core.errors import ServiceError |
| 12 | +from app.core.logging import get_logger |
| 13 | +from app.models import EntitiesPublic, EntityCreate, EntityPublic, EntityUpdate |
| 14 | + |
| 15 | +logger = get_logger(module=__name__) |
| 16 | + |
| 17 | +_TABLE = "entities" |
| 18 | + |
| 19 | +# Maximum number of entities that can be fetched in a single list call. |
| 20 | +_MAX_LIMIT = 100 |
| 21 | + |
| 22 | + |
| 23 | +def create_entity(supabase: Client, data: EntityCreate, owner_id: str) -> EntityPublic: |
| 24 | + """Insert a new entity owned by owner_id. Returns EntityPublic. |
| 25 | +
|
| 26 | + Args: |
| 27 | + supabase: Authenticated Supabase client. |
| 28 | + data: Validated creation payload. |
| 29 | + owner_id: Clerk user ID that will own the new entity. |
| 30 | +
|
| 31 | + Returns: |
| 32 | + The newly created entity as :class:`~app.models.entity.EntityPublic`. |
| 33 | +
|
| 34 | + Raises: |
| 35 | + ServiceError: 500 if the Supabase operation fails. |
| 36 | + """ |
| 37 | + payload = { |
| 38 | + "title": data.title, |
| 39 | + "description": data.description, |
| 40 | + "owner_id": owner_id, |
| 41 | + } |
| 42 | + try: |
| 43 | + response = supabase.table(_TABLE).insert(payload).execute() |
| 44 | + except Exception as exc: |
| 45 | + logger.error("Failed to create entity", error=str(exc)) |
| 46 | + raise ServiceError( |
| 47 | + status_code=500, |
| 48 | + message="Failed to create entity", |
| 49 | + code="ENTITY_CREATE_FAILED", |
| 50 | + ) from exc |
| 51 | + |
| 52 | + if not response.data: |
| 53 | + raise ServiceError( |
| 54 | + status_code=500, |
| 55 | + message="Entity insert returned no data", |
| 56 | + code="ENTITY_CREATE_FAILED", |
| 57 | + ) |
| 58 | + |
| 59 | + return EntityPublic(**response.data[0]) # type: ignore[arg-type] |
| 60 | + |
| 61 | + |
| 62 | +def get_entity(supabase: Client, entity_id: str, owner_id: str) -> EntityPublic: |
| 63 | + """Fetch a single entity by ID and owner. |
| 64 | +
|
| 65 | + Args: |
| 66 | + supabase: Authenticated Supabase client. |
| 67 | + entity_id: UUID string of the entity to retrieve. |
| 68 | + owner_id: Clerk user ID used to enforce ownership. |
| 69 | +
|
| 70 | + Returns: |
| 71 | + The matching entity as :class:`~app.models.entity.EntityPublic`. |
| 72 | +
|
| 73 | + Raises: |
| 74 | + ServiceError: 404 if the entity does not exist or is not owned by owner_id. |
| 75 | + ServiceError: 500 if a database or network error occurs. |
| 76 | + """ |
| 77 | + try: |
| 78 | + response = ( |
| 79 | + supabase.table(_TABLE) |
| 80 | + .select("*") |
| 81 | + .eq("id", entity_id) |
| 82 | + .eq("owner_id", owner_id) |
| 83 | + .single() |
| 84 | + .execute() |
| 85 | + ) |
| 86 | + except APIError as exc: |
| 87 | + raise ServiceError( |
| 88 | + status_code=404, |
| 89 | + message="Entity not found", |
| 90 | + code="ENTITY_NOT_FOUND", |
| 91 | + ) from exc |
| 92 | + except Exception as exc: |
| 93 | + logger.error("Failed to get entity", entity_id=entity_id, error=str(exc)) |
| 94 | + raise ServiceError( |
| 95 | + status_code=500, |
| 96 | + message="Failed to retrieve entity", |
| 97 | + code="ENTITY_GET_FAILED", |
| 98 | + ) from exc |
| 99 | + |
| 100 | + return EntityPublic(**response.data) # type: ignore[arg-type] |
| 101 | + |
| 102 | + |
| 103 | +def list_entities( |
| 104 | + supabase: Client, |
| 105 | + owner_id: str, |
| 106 | + *, |
| 107 | + offset: int = 0, |
| 108 | + limit: int = 20, |
| 109 | +) -> EntitiesPublic: |
| 110 | + """List entities for owner with pagination. Caps limit at 100. |
| 111 | +
|
| 112 | + Args: |
| 113 | + supabase: Authenticated Supabase client. |
| 114 | + owner_id: Clerk user ID used to filter entities by ownership. |
| 115 | + offset: Zero-based index of the first record to return (default 0). |
| 116 | + limit: Maximum number of records to return (default 20, capped at 100). |
| 117 | +
|
| 118 | + Returns: |
| 119 | + :class:`~app.models.entity.EntitiesPublic` with ``data`` list and total ``count``. |
| 120 | +
|
| 121 | + Raises: |
| 122 | + ServiceError: 500 if the Supabase operation fails. |
| 123 | + """ |
| 124 | + offset = max(0, offset) |
| 125 | + limit = max(1, min(limit, _MAX_LIMIT)) |
| 126 | + end = offset + limit - 1 |
| 127 | + |
| 128 | + try: |
| 129 | + response = ( |
| 130 | + supabase.table(_TABLE) |
| 131 | + .select("*", count="exact") # type: ignore[arg-type] |
| 132 | + .eq("owner_id", owner_id) |
| 133 | + .range(offset, end) |
| 134 | + .execute() |
| 135 | + ) |
| 136 | + except Exception as exc: |
| 137 | + logger.error("Failed to list entities", error=str(exc)) |
| 138 | + raise ServiceError( |
| 139 | + status_code=500, |
| 140 | + message="Failed to list entities", |
| 141 | + code="ENTITY_LIST_FAILED", |
| 142 | + ) from exc |
| 143 | + |
| 144 | + items = [EntityPublic(**row) for row in response.data] # type: ignore[arg-type] |
| 145 | + return EntitiesPublic(data=items, count=response.count or 0) |
| 146 | + |
| 147 | + |
| 148 | +def update_entity( |
| 149 | + supabase: Client, |
| 150 | + entity_id: str, |
| 151 | + owner_id: str, |
| 152 | + data: EntityUpdate, |
| 153 | +) -> EntityPublic: |
| 154 | + """Partially update an entity. Raises ServiceError(404) if not found or not owned. |
| 155 | +
|
| 156 | + When ``data`` contains no fields (all values are unset), the function skips the |
| 157 | + UPDATE call and returns the current entity unchanged. |
| 158 | +
|
| 159 | + Args: |
| 160 | + supabase: Authenticated Supabase client. |
| 161 | + entity_id: UUID string of the entity to update. |
| 162 | + owner_id: Clerk user ID used to enforce ownership. |
| 163 | + data: Partial update payload. Only provided fields are written. |
| 164 | +
|
| 165 | + Returns: |
| 166 | + The updated (or unchanged) entity as :class:`~app.models.entity.EntityPublic`. |
| 167 | +
|
| 168 | + Raises: |
| 169 | + ServiceError: 404 if the entity does not exist or is not owned by owner_id. |
| 170 | + ServiceError: 500 if the Supabase operation fails. |
| 171 | + """ |
| 172 | + fields = data.model_dump(exclude_unset=True) |
| 173 | + |
| 174 | + # No-op: no fields provided — fetch and return the current entity. |
| 175 | + if not fields: |
| 176 | + return get_entity(supabase, entity_id, owner_id) |
| 177 | + |
| 178 | + try: |
| 179 | + response = ( |
| 180 | + supabase.table(_TABLE) |
| 181 | + .update(fields) |
| 182 | + .eq("id", entity_id) |
| 183 | + .eq("owner_id", owner_id) |
| 184 | + .execute() |
| 185 | + ) |
| 186 | + except Exception as exc: |
| 187 | + logger.error("Failed to update entity", entity_id=entity_id, error=str(exc)) |
| 188 | + raise ServiceError( |
| 189 | + status_code=500, |
| 190 | + message="Failed to update entity", |
| 191 | + code="ENTITY_UPDATE_FAILED", |
| 192 | + ) from exc |
| 193 | + |
| 194 | + if not response.data: |
| 195 | + raise ServiceError( |
| 196 | + status_code=404, |
| 197 | + message="Entity not found", |
| 198 | + code="ENTITY_NOT_FOUND", |
| 199 | + ) |
| 200 | + |
| 201 | + return EntityPublic(**response.data[0]) # type: ignore[arg-type] |
| 202 | + |
| 203 | + |
| 204 | +def delete_entity(supabase: Client, entity_id: str, owner_id: str) -> None: |
| 205 | + """Delete an entity. Raises ServiceError(404) if not found or not owned. |
| 206 | +
|
| 207 | + Args: |
| 208 | + supabase: Authenticated Supabase client. |
| 209 | + entity_id: UUID string of the entity to delete. |
| 210 | + owner_id: Clerk user ID used to enforce ownership. |
| 211 | +
|
| 212 | + Returns: |
| 213 | + None on success. |
| 214 | +
|
| 215 | + Raises: |
| 216 | + ServiceError: 404 if the entity does not exist or is not owned by owner_id. |
| 217 | + ServiceError: 500 if the Supabase operation fails. |
| 218 | + """ |
| 219 | + try: |
| 220 | + response = ( |
| 221 | + supabase.table(_TABLE) |
| 222 | + .delete() |
| 223 | + .eq("id", entity_id) |
| 224 | + .eq("owner_id", owner_id) |
| 225 | + .execute() |
| 226 | + ) |
| 227 | + except Exception as exc: |
| 228 | + logger.error("Failed to delete entity", entity_id=entity_id, error=str(exc)) |
| 229 | + raise ServiceError( |
| 230 | + status_code=500, |
| 231 | + message="Failed to delete entity", |
| 232 | + code="ENTITY_DELETE_FAILED", |
| 233 | + ) from exc |
| 234 | + |
| 235 | + if not response.data: |
| 236 | + raise ServiceError( |
| 237 | + status_code=404, |
| 238 | + message="Entity not found", |
| 239 | + code="ENTITY_NOT_FOUND", |
| 240 | + ) |
0 commit comments