Add POI feature and imporve the overall backend implementation

This commit is contained in:
Urtzi Alfaro
2025-11-12 15:34:10 +01:00
parent e8096cd979
commit 5783c7ed05
173 changed files with 16862 additions and 9078 deletions

View File

@@ -16,6 +16,8 @@ from shared.auth.access_control import require_user_role, admin_role_required, s
from shared.routing import RouteBuilder
from app.services.pos_transaction_service import POSTransactionService
from app.services.pos_config_service import POSConfigurationService
from app.services.pos_webhook_service import POSWebhookService
from app.services.pos_sync_service import POSSyncService
from app.services.tenant_deletion_service import POSTenantDeletionService
router = APIRouter()
@@ -44,20 +46,44 @@ async def trigger_sync(
data_types = sync_request.get("data_types", ["transactions"])
config_id = sync_request.get("config_id")
if not config_id:
raise HTTPException(status_code=400, detail="config_id is required")
# Get POS configuration to determine system type
config_service = POSConfigurationService()
configs = await config_service.get_configurations_by_tenant(tenant_id, skip=0, limit=100)
config = next((c for c in configs if str(c.id) == str(config_id)), None)
if not config:
raise HTTPException(status_code=404, detail="POS configuration not found")
# Create sync job
sync_service = POSSyncService(db)
sync_log = await sync_service.create_sync_job(
tenant_id=tenant_id,
pos_config_id=UUID(config_id),
pos_system=config.pos_system,
sync_type=sync_type,
data_types=data_types
)
logger.info("Manual sync triggered",
tenant_id=tenant_id,
config_id=config_id,
sync_id=str(sync_log.id),
sync_type=sync_type,
user_id=current_user.get("user_id"))
return {
"message": "Sync triggered successfully",
"sync_id": "placeholder-sync-id",
"sync_id": str(sync_log.id),
"status": "queued",
"sync_type": sync_type,
"data_types": data_types,
"estimated_duration": "5-10 minutes"
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to trigger sync", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to trigger sync: {str(e)}")
@@ -78,6 +104,7 @@ async def get_sync_status(
"""Get synchronization status and recent sync history"""
try:
transaction_service = POSTransactionService()
sync_service = POSSyncService(db)
# Get sync metrics from transaction service
sync_metrics = await transaction_service.get_sync_metrics(tenant_id)
@@ -91,6 +118,13 @@ async def get_sync_status(
synced = sync_status.get("synced", 0)
success_rate = (synced / total * 100) if total > 0 else 100.0
# Calculate actual average duration from sync logs
average_duration_minutes = await sync_service.calculate_average_duration(
tenant_id=tenant_id,
pos_config_id=config_id,
days=30
)
return {
"current_sync": None,
"last_successful_sync": last_successful_sync.isoformat() if last_successful_sync else None,
@@ -98,7 +132,7 @@ async def get_sync_status(
"sync_health": {
"status": "healthy" if success_rate > 90 else "degraded" if success_rate > 70 else "unhealthy",
"success_rate": round(success_rate, 2),
"average_duration_minutes": 3.2, # Placeholder - could calculate from actual data
"average_duration_minutes": average_duration_minutes,
"last_error": None,
"total_transactions": total,
"synced_count": synced,
@@ -128,11 +162,19 @@ async def get_sync_logs(
):
"""Get detailed sync logs"""
try:
return {
"logs": [],
"total": 0,
"has_more": False
}
sync_service = POSSyncService(db)
logs_data = await sync_service.get_sync_logs(
tenant_id=tenant_id,
config_id=config_id,
status=status,
sync_type=sync_type,
limit=limit,
offset=offset
)
return logs_data
except Exception as e:
logger.error("Failed to get sync logs", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get sync logs: {str(e)}")
@@ -151,17 +193,44 @@ async def resync_failed_transactions(
):
"""Resync failed transactions from the specified time period (Admin/Owner only)"""
try:
# Get active POS configuration for tenant
config_service = POSConfigurationService()
configs = await config_service.get_configurations_by_tenant(
tenant_id=tenant_id,
is_active=True,
skip=0,
limit=1
)
if not configs:
raise HTTPException(status_code=404, detail="No active POS configuration found")
config = configs[0]
# Create resync job
sync_service = POSSyncService(db)
sync_log = await sync_service.create_sync_job(
tenant_id=tenant_id,
pos_config_id=config.id,
pos_system=config.pos_system,
sync_type="resync_failed",
data_types=["transactions"]
)
logger.info("Resync failed transactions requested",
tenant_id=tenant_id,
days_back=days_back,
sync_id=str(sync_log.id),
user_id=current_user.get("user_id"))
return {
"message": "Resync job queued successfully",
"job_id": "placeholder-resync-job-id",
"job_id": str(sync_log.id),
"scope": f"Failed transactions from last {days_back} days",
"estimated_transactions": 0
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to queue resync job", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to queue resync job: {str(e)}")
@@ -229,12 +298,17 @@ async def receive_webhook(
content_type: Optional[str] = Header(None),
x_signature: Optional[str] = Header(None),
x_webhook_signature: Optional[str] = Header(None),
authorization: Optional[str] = Header(None)
authorization: Optional[str] = Header(None),
db=Depends(get_db)
):
"""
Receive webhooks from POS systems
Supports Square, Toast, and Lightspeed webhook formats
Includes signature verification, database logging, and duplicate detection
"""
webhook_service = POSWebhookService(db)
start_time = datetime.utcnow()
try:
# Validate POS system
supported_systems = ["square", "toast", "lightspeed"]
@@ -273,63 +347,112 @@ async def receive_webhook(
# Determine signature from various header formats
signature = x_signature or x_webhook_signature or authorization
# Log webhook receipt
logger.info("Webhook received",
pos_system=pos_system,
method=method,
url_path=url_path,
payload_size=payload_size,
client_ip=client_ip,
has_signature=bool(signature),
content_type=content_type)
# TODO: Store webhook log in database
# TODO: Verify webhook signature
# TODO: Extract tenant_id from payload
# TODO: Process webhook based on POS system type
# TODO: Queue for async processing if needed
# Parse webhook type based on POS system
webhook_type = None
event_id = None
# Parse webhook event details
event_details = webhook_service.parse_webhook_event_details(pos_system, parsed_payload or {})
webhook_type = event_details.get("webhook_type") or "unknown"
event_id = event_details.get("event_id")
transaction_id = event_details.get("transaction_id")
order_id = event_details.get("order_id")
# Extract tenant_id from payload
tenant_id = None
if parsed_payload:
if pos_system.lower() == "square":
webhook_type = parsed_payload.get("type")
event_id = parsed_payload.get("event_id")
elif pos_system.lower() == "toast":
webhook_type = parsed_payload.get("eventType")
event_id = parsed_payload.get("guid")
elif pos_system.lower() == "lightspeed":
webhook_type = parsed_payload.get("action")
event_id = parsed_payload.get("id")
tenant_id = await webhook_service.extract_tenant_id_from_payload(pos_system, parsed_payload)
logger.info("Webhook processed successfully",
# Check for duplicate webhook
is_duplicate = False
if event_id:
is_duplicate, _ = await webhook_service.check_duplicate_webhook(
pos_system, event_id, tenant_id
)
# Verify webhook signature if tenant is identified
is_signature_valid = None
if signature and tenant_id:
webhook_secret = await webhook_service.get_webhook_secret(pos_system, tenant_id)
if webhook_secret:
is_signature_valid = await webhook_service.verify_webhook_signature(
pos_system, raw_payload, signature, webhook_secret
)
if not is_signature_valid:
logger.warning("Webhook signature verification failed",
pos_system=pos_system,
tenant_id=str(tenant_id))
# Log webhook receipt to database
webhook_log = await webhook_service.log_webhook(
pos_system=pos_system,
webhook_type=webhook_type,
method=method,
url_path=url_path,
query_params=query_params,
headers=headers,
raw_payload=raw_payload,
payload_size=payload_size,
content_type=content_type,
signature=signature,
is_signature_valid=is_signature_valid,
source_ip=client_ip,
event_id=event_id,
tenant_id=tenant_id,
transaction_id=transaction_id,
order_id=order_id
)
# Mark as duplicate if detected
if is_duplicate:
await webhook_service.update_webhook_status(
webhook_log.id,
status="duplicate",
error_message="Duplicate event already processed"
)
logger.info("Duplicate webhook ignored", event_id=event_id)
return _get_webhook_response(pos_system, success=True)
# TODO: Queue for async processing if needed
# For now, mark as received and ready for processing
processing_duration_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
await webhook_service.update_webhook_status(
webhook_log.id,
status="queued",
processing_duration_ms=processing_duration_ms
)
logger.info("Webhook processed and queued successfully",
pos_system=pos_system,
webhook_type=webhook_type,
event_id=event_id)
event_id=event_id,
tenant_id=str(tenant_id) if tenant_id else None,
webhook_log_id=str(webhook_log.id))
# Return appropriate response based on POS system requirements
if pos_system.lower() == "square":
return {"status": "success"}
elif pos_system.lower() == "toast":
return {"success": True}
elif pos_system.lower() == "lightspeed":
return {"received": True}
else:
return {"status": "received"}
return _get_webhook_response(pos_system, success=True)
except HTTPException:
raise
except Exception as e:
logger.error("Webhook processing failed",
error=str(e),
pos_system=pos_system)
pos_system=pos_system,
exc_info=True)
# Return 500 to trigger POS system retry
raise HTTPException(status_code=500, detail="Webhook processing failed")
def _get_webhook_response(pos_system: str, success: bool = True) -> Dict[str, Any]:
"""Get POS-specific webhook response format"""
if pos_system.lower() == "square":
return {"status": "success" if success else "error"}
elif pos_system.lower() == "toast":
return {"success": success}
elif pos_system.lower() == "lightspeed":
return {"received": success}
else:
return {"status": "received" if success else "error"}
@router.get(
route_builder.build_webhook_route("{pos_system}/status"),
response_model=dict
@@ -495,3 +618,189 @@ async def preview_tenant_data_deletion(
status_code=500,
detail=f"Failed to preview tenant data deletion: {str(e)}"
)
# ================================================================
# POS TO SALES SYNC ENDPOINTS
# ================================================================
@router.post(
"/tenants/{tenant_id}/pos/transactions/{transaction_id}/sync-to-sales",
summary="Sync single transaction to sales",
description="Manually sync a specific POS transaction to the sales service"
)
async def sync_transaction_to_sales(
tenant_id: UUID = Path(..., description="Tenant ID"),
transaction_id: UUID = Path(..., description="Transaction ID to sync"),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Sync a single POS transaction to the sales service
This endpoint:
- Creates sales records for each item in the transaction
- Automatically decreases inventory stock
- Updates sync status flags
- Returns detailed sync results
"""
try:
from app.services.pos_transaction_service import POSTransactionService
transaction_service = POSTransactionService()
result = await transaction_service.sync_transaction_to_sales(
transaction_id=transaction_id,
tenant_id=tenant_id
)
if result.get("success"):
logger.info("Transaction synced to sales via API",
transaction_id=transaction_id,
tenant_id=tenant_id,
user_id=current_user.get("user_id"))
return {
"success": True,
"message": "Transaction synced successfully",
**result
}
else:
logger.warning("Transaction sync failed via API",
transaction_id=transaction_id,
tenant_id=tenant_id,
error=result.get("error"))
raise HTTPException(
status_code=400,
detail=result.get("error", "Failed to sync transaction")
)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to sync transaction to sales",
error=str(e),
transaction_id=transaction_id,
tenant_id=tenant_id,
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to sync transaction: {str(e)}"
)
@router.post(
"/tenants/{tenant_id}/pos/transactions/sync-all-to-sales",
summary="Batch sync unsynced transactions",
description="Sync all unsynced POS transactions to the sales service"
)
async def sync_all_transactions_to_sales(
tenant_id: UUID = Path(..., description="Tenant ID"),
limit: int = Query(50, ge=1, le=200, description="Max transactions to sync in one batch"),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Batch sync all unsynced POS transactions to the sales service
This endpoint:
- Finds all unsynced completed transactions
- Syncs each one to the sales service
- Creates sales records and decreases inventory
- Returns summary with success/failure counts
Use this to:
- Manually trigger sync after POS webhooks are received
- Recover from sync failures
- Initial migration of historical POS data
"""
try:
from app.services.pos_transaction_service import POSTransactionService
transaction_service = POSTransactionService()
result = await transaction_service.sync_unsynced_transactions(
tenant_id=tenant_id,
limit=limit
)
logger.info("Batch sync completed via API",
tenant_id=tenant_id,
total=result.get("total_transactions"),
synced=result.get("synced"),
failed=result.get("failed"),
user_id=current_user.get("user_id"))
return {
"success": True,
"message": f"Synced {result.get('synced')} of {result.get('total_transactions')} transactions",
**result
}
except Exception as e:
logger.error("Failed to batch sync transactions to sales",
error=str(e),
tenant_id=tenant_id,
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to batch sync transactions: {str(e)}"
)
@router.get(
"/tenants/{tenant_id}/pos/transactions/sync-status",
summary="Get sync status summary",
description="Get summary of synced vs unsynced transactions"
)
async def get_sync_status(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Get sync status summary for POS transactions
Returns counts of:
- Total completed transactions
- Synced transactions
- Unsynced transactions
- Failed sync attempts
"""
try:
from app.services.pos_transaction_service import POSTransactionService
transaction_service = POSTransactionService()
# Get counts for different sync states
total_completed = await transaction_service.count_transactions_by_tenant(
tenant_id=tenant_id,
status="completed"
)
synced = await transaction_service.count_transactions_by_tenant(
tenant_id=tenant_id,
status="completed",
is_synced=True
)
unsynced = await transaction_service.count_transactions_by_tenant(
tenant_id=tenant_id,
status="completed",
is_synced=False
)
return {
"total_completed_transactions": total_completed,
"synced_to_sales": synced,
"pending_sync": unsynced,
"sync_rate": round((synced / total_completed * 100) if total_completed > 0 else 0, 2)
}
except Exception as e:
logger.error("Failed to get sync status",
error=str(e),
tenant_id=tenant_id,
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to get sync status: {str(e)}"
)