Michael-Antony's picture
feat: task geofence validation against destination merchant, shared haversine utility
9c3b067
"""
API router for tasks endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from app.core.logging import get_logger
from app.dependencies.auth import get_current_user, TokenUser
from app.nosql import get_database
from app.tracker.tasks.service import TaskService, get_task_service
from app.tracker.tasks.schemas import (
TodayTasksResponse,
ErrorResponse,
UpdateTaskStatusRequest,
UpdateTaskStatusResponse,
AttachmentType,
UploadAttachmentResponse
)
logger = get_logger(__name__)
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get(
"/today",
response_model=TodayTasksResponse,
status_code=status.HTTP_200_OK,
responses={
200: {"description": "Today's tasks retrieved successfully"},
401: {"description": "Unauthorized"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Get Today's Tasks",
description="""
Fetch all tasks assigned to the authenticated employee for today.
- Returns tasks ordered by scheduled time
- Includes task status, location, and scheduled time
- Status: not_started, in_progress, completed
"""
)
async def get_today_tasks(
current_user: TokenUser = Depends(get_current_user)
) -> TodayTasksResponse:
"""
Get all tasks assigned to the authenticated employee for today.
Args:
current_user: Authenticated user from JWT token
Returns:
TodayTasksResponse with list of tasks and count
Raises:
HTTPException 500: If internal error occurs
"""
try:
# Create service instance
service = get_task_service(get_database())
# Fetch today's tasks
tasks = await service.get_today_tasks(
employee_id=current_user.user_id,
merchant_id=current_user.merchant_id
)
logger.info(
"Today's tasks retrieved successfully",
extra={
"employee_id": current_user.user_id,
"merchant_id": current_user.merchant_id,
"task_count": len(tasks)
}
)
return TodayTasksResponse(
success=True,
tasks=tasks,
count=len(tasks)
)
except Exception as e:
# Unexpected errors
logger.error(
"Failed to retrieve today's tasks",
extra={
"employee_id": current_user.user_id,
"error": str(e)
},
exc_info=e
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"success": False,
"error": "Internal server error",
"detail": "An unexpected error occurred while retrieving tasks"
}
)
@router.patch(
"/{task_id}/status",
response_model=UpdateTaskStatusResponse,
status_code=status.HTTP_200_OK,
responses={
200: {"description": "Task status updated successfully"},
400: {"model": ErrorResponse, "description": "Bad request - invalid status transition"},
401: {"description": "Unauthorized"},
404: {"model": ErrorResponse, "description": "Task not found or not authorized"},
422: {"description": "Validation error"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Update Task Status",
description="""
Update the status of a task with location and timestamp tracking.
- Status: not_started, in_progress, completed
- Location and timestamp are mandatory
- Only assigned employee can update
- Captures started_at and completed_at timestamps
"""
)
async def update_task_status(
task_id: str,
payload: UpdateTaskStatusRequest,
current_user: TokenUser = Depends(get_current_user)
) -> UpdateTaskStatusResponse:
"""
Update the status of a task.
Args:
task_id: Task UUID
payload: Update request with status, timestamp, and location
current_user: Authenticated user from JWT token
Returns:
UpdateTaskStatusResponse with success status
Raises:
HTTPException 400: If invalid status transition
HTTPException 404: If task not found or not authorized
HTTPException 500: If internal error occurs
"""
try:
# Create service instance
service = get_task_service(get_database())
# Update task status
await service.update_task_status(
task_id=task_id,
employee_id=current_user.user_id,
merchant_id=current_user.merchant_id,
payload=payload
)
logger.info(
"Task status updated successfully",
extra={
"task_id": task_id,
"employee_id": current_user.user_id,
"new_status": payload.status.value
}
)
return UpdateTaskStatusResponse(
success=True,
message="Task status updated successfully"
)
except ValueError as e:
# Business logic errors (not found, not authorized, invalid transition)
error_message = str(e)
# Determine appropriate status code
if "not found" in error_message.lower():
status_code = status.HTTP_404_NOT_FOUND
elif "not authorized" in error_message.lower():
status_code = status.HTTP_404_NOT_FOUND # Don't reveal task existence
elif "transition" in error_message.lower():
status_code = status.HTTP_400_BAD_REQUEST
else:
status_code = status.HTTP_400_BAD_REQUEST
logger.warning(
f"Task status update validation failed: {error_message}",
extra={
"task_id": task_id,
"employee_id": current_user.user_id,
"error": error_message
}
)
raise HTTPException(
status_code=status_code,
detail={
"success": False,
"error": error_message,
"detail": error_message
}
)
except Exception as e:
# Unexpected errors
logger.error(
"Task status update failed with unexpected error",
extra={
"task_id": task_id,
"employee_id": current_user.user_id,
"error": str(e)
},
exc_info=e
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"success": False,
"error": "Internal server error",
"detail": "An unexpected error occurred while updating task status"
}
)
@router.post(
"/{task_id}/attachments",
response_model=UploadAttachmentResponse,
status_code=status.HTTP_201_CREATED,
responses={
201: {"description": "Attachment uploaded successfully"},
400: {"model": ErrorResponse, "description": "Bad request - invalid file type or size"},
401: {"description": "Unauthorized"},
404: {"model": ErrorResponse, "description": "Task not found or not authorized"},
413: {"model": ErrorResponse, "description": "File too large"},
415: {"model": ErrorResponse, "description": "Unsupported media type"},
422: {"description": "Validation error"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Upload Task Attachment",
description="""
Upload an attachment (photo or signature) for a task.
**Attachment Types:**
- photo: Site proof or progress photos
- signature: Customer confirmation signature
**File Requirements:**
- Images only (JPEG, PNG, WebP)
- Maximum file size: 10MB
- Multipart/form-data encoding required
**Rules:**
- Only the assigned employee can upload attachments
- Task must belong to employee's merchant
- Files are stored in MinIO object storage
- Public URL is returned for accessing the file
**Request Format:**
- Content-Type: multipart/form-data
- file: Binary file data (image)
- type: "photo" or "signature"
**Authentication:**
- Requires valid JWT token
- Task must be assigned to the authenticated employee
**Storage:**
- Files stored in MinIO bucket: cutra-tracker-attachments
- Path format: {merchant_id}/tasks/{task_id}/{type}_{id}_{filename}
- Database record created in trans.scm_task_attachments
""",
)
async def upload_attachment(
task_id: str,
file: UploadFile = File(..., description="Image file to upload"),
type: AttachmentType = Form(..., description="Attachment type (photo or signature)"),
current_user: TokenUser = Depends(get_current_user)
) -> UploadAttachmentResponse:
"""
Upload an attachment for a task.
Args:
task_id: Task UUID
file: Uploaded file (image)
type: Attachment type (photo or signature)
current_user: Authenticated user from JWT token
Returns:
UploadAttachmentResponse with success status and file URL
Raises:
HTTPException 400: If invalid file type or size
HTTPException 404: If task not found or not authorized
HTTPException 413: If file too large
HTTPException 415: If unsupported media type
HTTPException 500: If internal error occurs
"""
try:
# Read file data
file_data = await file.read()
# Get file info
file_name = file.filename or "attachment"
mime_type = file.content_type or "application/octet-stream"
logger.info(
"Processing attachment upload",
extra={
"task_id": task_id,
"employee_id": current_user.user_id,
"file_name": file_name,
"mime_type": mime_type,
"file_size": len(file_data),
"attachment_type": type.value
}
)
# Create service instance
service = get_task_service(get_database())
# Upload attachment
result = await service.upload_attachment(
task_id=task_id,
employee_id=current_user.user_id,
merchant_id=current_user.merchant_id,
file_data=file_data,
file_name=file_name,
mime_type=mime_type,
attachment_type=type
)
logger.info(
"Attachment uploaded successfully",
extra={
"task_id": task_id,
"attachment_id": result['id'],
"file_url": result['url']
}
)
return UploadAttachmentResponse(
success=True,
url=result['url'],
attachment_id=result['id'],
file_name=result['file_name']
)
except ValueError as e:
# Business logic errors (not found, not authorized, invalid file)
error_message = str(e)
# Determine appropriate status code
if "not found" in error_message.lower():
status_code = status.HTTP_404_NOT_FOUND
elif "not authorized" in error_message.lower():
status_code = status.HTTP_404_NOT_FOUND # Don't reveal task existence
elif "exceeds maximum" in error_message.lower():
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
elif "not allowed" in error_message.lower() or "only image" in error_message.lower():
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
else:
status_code = status.HTTP_400_BAD_REQUEST
logger.warning(
f"Attachment upload validation failed: {error_message}",
extra={
"task_id": task_id,
"employee_id": current_user.user_id,
"error": error_message
}
)
raise HTTPException(
status_code=status_code,
detail={
"success": False,
"error": error_message,
"detail": error_message
}
)
except Exception as e:
# Unexpected errors
logger.error(
"Attachment upload failed with unexpected error",
extra={
"task_id": task_id,
"employee_id": current_user.user_id,
"error": str(e)
},
exc_info=e
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"success": False,
"error": "Internal server error",
"detail": "An unexpected error occurred while uploading attachment"
}
)