This commit is contained in:
johnpccd 2025-05-24 23:38:01 +02:00
parent 87e330161b
commit c862775075
5 changed files with 203 additions and 210 deletions

108
README.md
View File

@ -6,7 +6,7 @@ SEREACT is a secure API for storing, organizing, and retrieving images with adva
- Secure image storage in Google Cloud Storage
- Team-based organization and access control
- API key authentication
- **Hybrid authentication model**: Public management endpoints + API key protected data endpoints
- **Asynchronous image processing with Pub/Sub and Cloud Functions**
- **AI-powered image embeddings using Google Cloud Vision API**
- **Semantic search using vector similarity with Qdrant Vector Database**
@ -15,6 +15,7 @@ SEREACT is a secure API for storing, organizing, and retrieving images with adva
- Metadata extraction and storage
- Image processing capabilities
- Multi-team support
- **Public user and team management APIs for easy integration**
- **Comprehensive E2E testing with real database support**
## Architecture
@ -340,21 +341,41 @@ results = vector_db.search_similar_images(
## API Endpoints
The API provides the following main endpoints with their pagination support:
The API provides the following main endpoints with their authentication and pagination support:
### Authentication & Authorization
- `/api/v1/auth/*` - Authentication and API key management
- `GET /api/v1/auth/api-keys` - List API keys (no pagination - returns all keys for user)
### 🔓 **Public Endpoints (No Authentication Required)**
### Team Management
- `/api/v1/teams/*` - Team management
- `GET /api/v1/teams` - List teams (no pagination - admin only, returns all teams)
#### Authentication & API Key Management
- `/api/v1/auth/bootstrap` - Initial system setup (creates first team, admin user, and API key)
- `/api/v1/auth/api-keys` (POST) - Create new API key (requires `user_id` and `team_id` parameters)
### User Management
- `/api/v1/users/*` - User management
- `GET /api/v1/users` - List users (no pagination - returns all users in team/organization)
#### Team Management
- `/api/v1/teams/*` - **Complete team management (no authentication required)**
- `POST /api/v1/teams` - Create new team
- `GET /api/v1/teams` - List all teams (no pagination - returns all teams)
- `GET /api/v1/teams/{team_id}` - Get team by ID
- `PUT /api/v1/teams/{team_id}` - Update team
- `DELETE /api/v1/teams/{team_id}` - Delete team
### Image Management ✅ **Fully Paginated**
#### User Management
- `/api/v1/users/*` - **Complete user management (no authentication required)**
- `POST /api/v1/users` - Create new user (requires `team_id`)
- `GET /api/v1/users` - List users (no pagination - returns all users, optionally filtered by team)
- `GET /api/v1/users/{user_id}` - Get user by ID
- `PUT /api/v1/users/{user_id}` - Update user
- `DELETE /api/v1/users/{user_id}` - Delete user
- `GET /api/v1/users/me?user_id={id}` - Get user info (requires `user_id` parameter)
- `PUT /api/v1/users/me?user_id={id}` - Update user info (requires `user_id` parameter)
### 🔐 **Protected Endpoints (API Key Authentication Required)**
#### API Key Management (Authenticated)
- `/api/v1/auth/api-keys` (GET) - List API keys for current user
- `/api/v1/auth/api-keys/{key_id}` (DELETE) - Revoke API key
- `/api/v1/auth/admin/api-keys/{user_id}` (POST) - Create API key for another user (admin only)
- `/api/v1/auth/verify` - Verify current authentication
#### Image Management ✅ **Fully Paginated & Protected**
- `/api/v1/images/*` - **Image upload, download, and management (with async processing)**
- `GET /api/v1/images` - List images with **full pagination support**
- **Query Parameters:**
@ -364,7 +385,7 @@ The API provides the following main endpoints with their pagination support:
- `tags` (optional) - Filter by comma-separated tags
- **Response includes:** `images`, `total`, `skip`, `limit`
### Search Functionality ✅ **Fully Paginated**
#### Search Functionality ✅ **Fully Paginated & Protected**
- `/api/v1/search/*` - **Image search functionality (semantic search via Qdrant)**
- `GET /api/v1/search` - Search images with **pagination support**
- **Query Parameters:**
@ -377,17 +398,57 @@ The API provides the following main endpoints with their pagination support:
- `POST /api/v1/search` - Advanced search with same pagination
- `GET /api/v1/search/similar/{image_id}` - Find similar images with pagination
### Pagination Implementation Status
### 🔑 **Authentication Model**
| Endpoint | Pagination Status | Notes |
|----------|------------------|-------|
| `GET /api/v1/images` | ✅ **Fully Implemented** | `skip`, `limit`, `total` with proper validation |
| `GET /api/v1/search` | ✅ **Fully Implemented** | `limit`, `total` with similarity scoring |
| `GET /api/v1/users` | ❌ **Not Implemented** | Returns all users (typically small datasets) |
| `GET /api/v1/teams` | ❌ **Not Implemented** | Returns all teams (typically small datasets) |
| `GET /api/v1/auth/api-keys` | ❌ **Not Implemented** | Returns all keys for user (typically small datasets) |
SEREACT uses a **hybrid authentication model**:
**Note:** The endpoints without pagination (users, teams, API keys) typically return small datasets and are designed for admin/management use cases where full data visibility is preferred.
1. **Public Management Endpoints**: Users, teams, and API key creation are **publicly accessible** for easy integration and setup
2. **Protected Data Endpoints**: Image storage, search, and API key management require **API key authentication**
3. **Bootstrap Process**: Initial setup via `/auth/bootstrap` creates the first team, admin user, and API key
#### **Typical Workflow**:
```bash
# 1. Bootstrap initial setup (public)
POST /api/v1/auth/bootstrap
{
"team_name": "My Team",
"admin_email": "admin@example.com",
"admin_name": "Admin User"
}
# Returns: API key for subsequent authenticated requests
# 2. Create additional users (public)
POST /api/v1/users
{
"name": "John Doe",
"email": "john@example.com",
"team_id": "team_id_from_bootstrap"
}
# 3. Create API keys for users (public)
POST /api/v1/auth/api-keys?user_id={user_id}&team_id={team_id}
{
"name": "John's API Key",
"description": "For image uploads"
}
# 4. Use API key for protected operations
GET /api/v1/images
Headers: X-API-Key: your_api_key_here
```
### **Authentication & Pagination Status**
| Endpoint Category | Authentication | Pagination Status | Notes |
|------------------|----------------|------------------|-------|
| **Users Management** | 🔓 **Public** | ❌ **Not Implemented** | Complete CRUD operations, no auth required |
| **Teams Management** | 🔓 **Public** | ❌ **Not Implemented** | Complete CRUD operations, no auth required |
| **API Key Creation** | 🔓 **Public** | N/A | Requires `user_id` and `team_id` parameters |
| **Images API** | 🔐 **Protected** | ✅ **Fully Implemented** | `skip`, `limit`, `total` with proper validation |
| **Search API** | 🔐 **Protected** | ✅ **Fully Implemented** | `limit`, `total` with similarity scoring |
| **API Key Management** | 🔐 **Protected** | ❌ **Not Implemented** | List/revoke existing keys (small datasets) |
**Note:** Public endpoints (users, teams) don't implement pagination as they typically return small datasets and are designed for management use cases where full data visibility is preferred.
### **Image Processing Status**
@ -599,6 +660,7 @@ This modular architecture provides several benefits:
### Low Priority
- [ ] Terraform dependencies
- [ ] Move all auth logic to auth module
### Pagination Status ✅
- **✅ Images API**: Fully implemented with `skip`, `limit`, `total` parameters
@ -606,6 +668,8 @@ This modular architecture provides several benefits:
- ** Users/Teams/API Keys**: No pagination (small datasets, admin use cases)
## Recent Changes
- **Implemented hybrid authentication model**: Users, teams, and API key creation are now publicly accessible
- **Removed authentication requirements** from user and team management endpoints for easier integration
- Migrated from Pinecone to self-hosted Qdrant
- Added Cloud Function for async image processing
- Implemented vector similarity search

12
main.py
View File

@ -140,10 +140,16 @@ def custom_openapi():
if "schemas" not in openapi_schema["components"]:
openapi_schema["components"]["schemas"] = {}
# Apply security to all endpoints except auth endpoints
# Apply security to endpoints except auth, users, teams, and API key creation endpoints
for path in openapi_schema["paths"]:
if not path.startswith("/api/v1/auth"):
openapi_schema["paths"][path]["security"] = [{"ApiKeyAuth": []}]
# Exclude auth endpoints, users endpoints, teams endpoints, and API key creation
if not (path.startswith("/api/v1/auth") or
path.startswith("/api/v1/users") or
path.startswith("/api/v1/teams")):
for method in openapi_schema["paths"][path]:
if method.lower() in ["get", "post", "put", "delete", "patch"]:
if "security" not in openapi_schema["paths"][path][method]:
openapi_schema["paths"][path][method]["security"] = [{"ApiKeyAuth": []}]
app.openapi_schema = openapi_schema
return app.openapi_schema

View File

@ -152,59 +152,45 @@ async def bootstrap_initial_setup(
raise HTTPException(status_code=500, detail=f"Bootstrap failed: {str(e)}")
@router.post("/api-keys", response_model=ApiKeyWithValueResponse, status_code=201)
async def create_api_key(key_data: ApiKeyCreate, request: Request, current_user = Depends(get_current_user)):
async def create_api_key(key_data: ApiKeyCreate, request: Request, user_id: str, team_id: str):
"""
Create a new API key
This endpoint no longer requires authentication - user_id and team_id must be provided
"""
log_request(
{"path": request.url.path, "method": request.method, "key_data": key_data.dict()},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "key_data": key_data.dict(), "user_id": user_id, "team_id": team_id}
)
# Determine target team and user
target_team_id = current_user.team_id
target_user_id = current_user.id
# Validate user_id and team_id
try:
target_user_id = ObjectId(user_id)
target_team_id = ObjectId(team_id)
except:
raise HTTPException(status_code=400, detail="Invalid user ID or team ID")
# If team_id is provided, use it (admin only)
if key_data.team_id:
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required to create API keys for other teams")
# Verify user exists
target_user = await user_repository.get_by_id(target_user_id)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
try:
target_team_id = ObjectId(key_data.team_id)
except:
raise HTTPException(status_code=400, detail="Invalid team ID")
# Verify team exists
team = await team_repository.get_by_id(target_team_id)
if not team:
raise HTTPException(status_code=404, detail="Target team not found")
# If user_id is provided, use it (admin only)
if key_data.user_id:
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required to create API keys for other users")
try:
target_user_id = ObjectId(key_data.user_id)
except:
raise HTTPException(status_code=400, detail="Invalid user ID")
# Verify user exists
target_user = await user_repository.get_by_id(target_user_id)
if not target_user:
raise HTTPException(status_code=404, detail="Target user not found")
# If user_id is provided but team_id is not, use the user's team
if not key_data.team_id:
target_team_id = target_user.team_id
# Check if target team exists
# Verify team exists
team = await team_repository.get_by_id(target_team_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Verify user belongs to the team
if target_user.team_id != target_team_id:
raise HTTPException(status_code=400, detail="User does not belong to the specified team")
# If team_id is provided in key_data, validate it matches the parameter
if key_data.team_id and key_data.team_id != team_id:
raise HTTPException(status_code=400, detail="Team ID in request body does not match parameter")
# If user_id is provided in key_data, validate it matches the parameter
if key_data.user_id and key_data.user_id != user_id:
raise HTTPException(status_code=400, detail="User ID in request body does not match parameter")
# Generate API key with expiry date
raw_key, hashed_key = generate_api_key(str(target_team_id), str(target_user_id))
expiry_date = calculate_expiry_date()

View File

@ -5,7 +5,6 @@ from bson import ObjectId
from src.db.repositories.team_repository import team_repository
from src.schemas.team import TeamCreate, TeamUpdate, TeamResponse, TeamListResponse
from src.models.team import TeamModel
from src.api.v1.auth import get_current_user
from src.utils.logging import log_request
logger = logging.getLogger(__name__)
@ -13,22 +12,16 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["Teams"], prefix="/teams")
@router.post("", response_model=TeamResponse, status_code=201)
async def create_team(team_data: TeamCreate, request: Request, current_user = Depends(get_current_user)):
async def create_team(team_data: TeamCreate, request: Request):
"""
Create a new team
This endpoint requires admin privileges
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "team_data": team_data.dict()},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "team_data": team_data.dict()}
)
# Only admins can create teams
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can create teams")
# Create team
team = TeamModel(
name=team_data.name,
@ -49,22 +42,16 @@ async def create_team(team_data: TeamCreate, request: Request, current_user = De
return response
@router.get("", response_model=TeamListResponse)
async def list_teams(request: Request, current_user = Depends(get_current_user)):
async def list_teams(request: Request):
"""
List all teams
This endpoint requires admin privileges
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method}
)
# Only admins can list all teams
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can list all teams")
# Get all teams
teams = await team_repository.get_all()
@ -82,16 +69,14 @@ async def list_teams(request: Request, current_user = Depends(get_current_user))
return TeamListResponse(teams=response_teams, total=len(response_teams))
@router.get("/{team_id}", response_model=TeamResponse)
async def get_team(team_id: str, request: Request, current_user = Depends(get_current_user)):
async def get_team(team_id: str, request: Request):
"""
Get a team by ID
Users can only access their own team unless they are an admin
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "team_id": team_id},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "team_id": team_id}
)
try:
@ -105,10 +90,6 @@ async def get_team(team_id: str, request: Request, current_user = Depends(get_cu
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Check if user can access this team
if str(team.id) != str(current_user.team_id) and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized to access this team")
# Convert to response model
response = TeamResponse(
id=str(team.id),
@ -121,22 +102,16 @@ async def get_team(team_id: str, request: Request, current_user = Depends(get_cu
return response
@router.put("/{team_id}", response_model=TeamResponse)
async def update_team(team_id: str, team_data: TeamUpdate, request: Request, current_user = Depends(get_current_user)):
async def update_team(team_id: str, team_data: TeamUpdate, request: Request):
"""
Update a team
This endpoint requires admin privileges
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "team_id": team_id, "team_data": team_data.dict()},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "team_id": team_id, "team_data": team_data.dict()}
)
# Only admins can update teams
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can update teams")
try:
# Convert string ID to ObjectId
obj_id = ObjectId(team_id)
@ -176,40 +151,28 @@ async def update_team(team_id: str, team_data: TeamUpdate, request: Request, cur
return response
@router.delete("/{team_id}", status_code=204)
async def delete_team(team_id: str, request: Request, current_user = Depends(get_current_user)):
async def delete_team(team_id: str, request: Request):
"""
Delete a team
This endpoint requires admin privileges
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "team_id": team_id},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "team_id": team_id}
)
# Only admins can delete teams
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can delete teams")
try:
# Convert string ID to ObjectId
obj_id = ObjectId(team_id)
except:
raise HTTPException(status_code=400, detail="Invalid team ID")
# Check if team exists
# Get the team
team = await team_repository.get_by_id(obj_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Don't allow deleting a user's own team
if str(team.id) == str(current_user.team_id):
raise HTTPException(status_code=400, detail="Cannot delete your own team")
# Delete the team
result = await team_repository.delete(obj_id)
if not result:
success = await team_repository.delete(obj_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete team")
return None

View File

@ -3,7 +3,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from bson import ObjectId
from src.api.v1.auth import get_current_user
# Remove the auth import since we're removing authentication
# from src.api.v1.auth import get_current_user
from src.db.repositories.user_repository import user_repository
from src.db.repositories.team_repository import team_repository
from src.models.user import UserModel
@ -17,15 +18,22 @@ router = APIRouter(tags=["Users"], prefix="/users")
@router.get("/me", response_model=UserResponse)
async def read_users_me(
request: Request,
current_user: UserModel = Depends(get_current_user)
user_id: str # Now requires user_id as a query parameter
):
"""Get current user information"""
"""Get user information by user ID"""
log_request(
{"path": request.url.path, "method": request.method},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "user_id": user_id}
)
try:
obj_id = ObjectId(user_id)
except:
raise HTTPException(status_code=400, detail="Invalid user ID")
current_user = await user_repository.get_by_id(obj_id)
if not current_user:
raise HTTPException(status_code=404, detail="User not found")
response = UserResponse(
id=str(current_user.id),
name=current_user.name,
@ -43,15 +51,22 @@ async def read_users_me(
async def update_current_user(
user_data: UserUpdate,
request: Request,
current_user: UserModel = Depends(get_current_user)
user_id: str # Now requires user_id as a query parameter
):
"""Update current user information"""
"""Update user information by user ID"""
log_request(
{"path": request.url.path, "method": request.method, "user_data": user_data.dict()},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "user_data": user_data.dict(), "user_id": user_id}
)
try:
obj_id = ObjectId(user_id)
except:
raise HTTPException(status_code=400, detail="Invalid user ID")
current_user = await user_repository.get_by_id(obj_id)
if not current_user:
raise HTTPException(status_code=404, detail="User not found")
# Update user
update_data = user_data.dict(exclude_unset=True)
if not update_data:
@ -88,34 +103,30 @@ async def update_current_user(
@router.post("", response_model=UserResponse, status_code=201)
async def create_user(
user_data: UserCreate,
request: Request,
current_user: UserModel = Depends(get_current_user)
request: Request
):
"""
Create a new user
This endpoint requires admin privileges
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "user_data": user_data.dict()},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "user_data": user_data.dict()}
)
# Only admins can create users
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can create users")
# Check if user with email already exists
existing_user = await user_repository.get_by_email(user_data.email)
if existing_user:
raise HTTPException(status_code=400, detail="User with this email already exists")
# Validate team exists if specified
team_id = user_data.team_id or current_user.team_id
team = await team_repository.get_by_id(ObjectId(team_id))
if not team:
raise HTTPException(status_code=400, detail="Team not found")
if user_data.team_id:
team = await team_repository.get_by_id(ObjectId(user_data.team_id))
if not team:
raise HTTPException(status_code=400, detail="Team not found")
team_id = user_data.team_id
else:
raise HTTPException(status_code=400, detail="Team ID is required")
# Create user
user = UserModel(
@ -144,32 +155,24 @@ async def create_user(
@router.get("", response_model=UserListResponse)
async def list_users(
request: Request,
team_id: Optional[str] = None,
current_user: UserModel = Depends(get_current_user)
team_id: Optional[str] = None
):
"""
List users
Admins can list all users or filter by team.
Non-admins can only list users from their own team.
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "team_id": team_id},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "team_id": team_id}
)
# Determine which team to filter by
if current_user.is_admin:
# Admins can specify team_id or get all users
filter_team_id = ObjectId(team_id) if team_id else None
else:
# Non-admins can only see their own team
filter_team_id = current_user.team_id
# Get users
if filter_team_id:
users = await user_repository.get_by_team(filter_team_id)
if team_id:
try:
filter_team_id = ObjectId(team_id)
users = await user_repository.get_by_team(filter_team_id)
except:
raise HTTPException(status_code=400, detail="Invalid team ID")
else:
users = await user_repository.get_all()
@ -192,19 +195,15 @@ async def list_users(
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
request: Request,
current_user: UserModel = Depends(get_current_user)
request: Request
):
"""
Get user by ID
Admins can get any user.
Non-admins can only get users from their own team.
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "user_id": user_id},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "user_id": user_id}
)
try:
@ -212,15 +211,10 @@ async def get_user(
except:
raise HTTPException(status_code=400, detail="Invalid user ID")
# Get user
user = await user_repository.get_by_id(obj_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check access permissions
if not current_user.is_admin and user.team_id != current_user.team_id:
raise HTTPException(status_code=403, detail="Not authorized to access this user")
response = UserResponse(
id=str(user.id),
name=user.name,
@ -238,30 +232,23 @@ async def get_user(
async def update_user(
user_id: str,
user_data: UserUpdate,
request: Request,
current_user: UserModel = Depends(get_current_user)
request: Request
):
"""
Update user
Update user by ID
This endpoint requires admin privileges
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "user_id": user_id},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "user_id": user_id, "user_data": user_data.dict()}
)
# Only admins can update other users
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can update users")
try:
obj_id = ObjectId(user_id)
except:
raise HTTPException(status_code=400, detail="Invalid user ID")
# Get user
# Check if user exists
user = await user_repository.get_by_id(obj_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
@ -302,41 +289,28 @@ async def update_user(
@router.delete("/{user_id}", status_code=204)
async def delete_user(
user_id: str,
request: Request,
current_user: UserModel = Depends(get_current_user)
request: Request
):
"""
Delete (deactivate) user
Delete user by ID
This endpoint requires admin privileges
This endpoint no longer requires authentication
"""
log_request(
{"path": request.url.path, "method": request.method, "user_id": user_id},
user_id=str(current_user.id),
team_id=str(current_user.team_id)
{"path": request.url.path, "method": request.method, "user_id": user_id}
)
# Only admins can delete users
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can delete users")
try:
obj_id = ObjectId(user_id)
except:
raise HTTPException(status_code=400, detail="Invalid user ID")
# Get user
# Check if user exists
user = await user_repository.get_by_id(obj_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent self-deletion
if obj_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Deactivate user instead of hard delete
result = await user_repository.update(obj_id, {"is_active": False})
if not result:
# Delete user
success = await user_repository.delete(obj_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete user")
return None