Coverage for src/alprina_cli/api/routes/cron.py: 29%
52 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Cron Job Routes
3Endpoints for scheduled tasks (called by external cron services like Render Cron Jobs)
4"""
5from fastapi import APIRouter, Header, HTTPException
6from pydantic import BaseModel
7from typing import Dict, Any
8import os
9from loguru import logger
11from ..services.abandoned_checkout_service import abandoned_checkout_service
13router = APIRouter()
16class CronResponse(BaseModel):
17 """Response model for cron jobs"""
18 success: bool
19 job_name: str
20 results: Dict[str, Any]
21 message: str
24def verify_cron_secret(authorization: str = Header(None)) -> bool:
25 """
26 Verify cron job is authorized
28 Checks for CRON_SECRET environment variable
29 Authorization header should be: Bearer {CRON_SECRET}
30 """
31 cron_secret = os.getenv('CRON_SECRET')
33 if not cron_secret:
34 logger.warning("CRON_SECRET not set - cron jobs are unprotected!")
35 return True # Allow if secret not set (for development)
37 if not authorization:
38 return False
40 if not authorization.startswith('Bearer '):
41 return False
43 provided_secret = authorization.replace('Bearer ', '')
44 return provided_secret == cron_secret
47@router.post("/cron/abandoned-checkout", response_model=CronResponse)
48async def run_abandoned_checkout_cron(
49 authorization: str = Header(None, alias="Authorization")
50):
51 """
52 Process abandoned checkouts - send reminder emails
54 This endpoint should be called by a cron job every hour.
56 **Authorization**: Requires `CRON_SECRET` in Authorization header as Bearer token
58 **Example**:
59 ```bash
60 curl -X POST https://api.alprina.com/v1/cron/abandoned-checkout \
61 -H "Authorization: Bearer your-cron-secret"
62 ```
64 **Render Cron Job Setup**:
65 1. Go to Render Dashboard → Create Cron Job
66 2. Name: "Abandoned Checkout Emails"
67 3. Schedule: `0 * * * *` (every hour)
68 4. Command: `curl -X POST https://api.alprina.com/v1/cron/abandoned-checkout -H "Authorization: Bearer $CRON_SECRET"`
69 5. Environment: Add CRON_SECRET variable
71 **What it does**:
72 - Finds users who signed up 1+ hours ago
73 - Haven't completed checkout (tier: none)
74 - Sends reminder email via Resend
75 - Marks as sent to avoid duplicates
76 """
77 # Verify authorization
78 if not verify_cron_secret(authorization):
79 logger.warning("Unauthorized cron job attempt")
80 raise HTTPException(
81 status_code=401,
82 detail="Unauthorized - invalid or missing CRON_SECRET"
83 )
85 try:
86 logger.info("🕐 Running abandoned checkout cron job")
88 # Process abandoned checkouts (users who signed up 1+ hours ago)
89 results = await abandoned_checkout_service.process_abandoned_checkouts(
90 hours_since_signup=1
91 )
93 message = f"Processed {results['found']} users: {results['sent']} emails sent, {results['failed']} failed"
94 logger.info(f"✅ {message}")
96 return CronResponse(
97 success=True,
98 job_name="abandoned_checkout",
99 results=results,
100 message=message
101 )
103 except Exception as e:
104 logger.error(f"❌ Abandoned checkout cron failed: {e}")
105 raise HTTPException(
106 status_code=500,
107 detail=f"Cron job failed: {str(e)}"
108 )
111@router.get("/cron/health")
112async def cron_health_check():
113 """
114 Health check for cron jobs
116 Returns status of cron job system
117 """
118 cron_secret_set = bool(os.getenv('CRON_SECRET'))
119 resend_api_key_set = bool(os.getenv('RESEND_API_KEY'))
121 return {
122 "status": "healthy",
123 "cron_secret_configured": cron_secret_set,
124 "resend_configured": resend_api_key_set,
125 "jobs_available": [
126 {
127 "name": "abandoned_checkout",
128 "endpoint": "/v1/cron/abandoned-checkout",
129 "method": "POST",
130 "schedule": "Every hour (0 * * * *)",
131 "description": "Send reminder emails to users who haven't completed checkout"
132 }
133 ]
134 }
137@router.post("/cron/test-email")
138async def test_abandoned_email(
139 authorization: str = Header(None, alias="Authorization"),
140 test_email: str = "test@example.com"
141):
142 """
143 Test abandoned checkout email (for development)
145 Sends a test email without checking database
146 """
147 if not verify_cron_secret(authorization):
148 raise HTTPException(status_code=401, detail="Unauthorized")
150 try:
151 # Create fake user data for testing
152 from datetime import datetime
153 test_user = {
154 "id": "test-user-id",
155 "email": test_email,
156 "full_name": "Test User",
157 "created_at": datetime.utcnow()
158 }
160 success = await abandoned_checkout_service.send_reminder_email(test_user)
162 if success:
163 return {
164 "success": True,
165 "message": f"Test email sent to {test_email}",
166 "email": test_email
167 }
168 else:
169 raise HTTPException(
170 status_code=500,
171 detail="Failed to send test email"
172 )
174 except Exception as e:
175 logger.error(f"Test email failed: {e}")
176 raise HTTPException(
177 status_code=500,
178 detail=f"Test failed: {str(e)}"
179 )