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

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 

10 

11from ..services.abandoned_checkout_service import abandoned_checkout_service 

12 

13router = APIRouter() 

14 

15 

16class CronResponse(BaseModel): 

17 """Response model for cron jobs""" 

18 success: bool 

19 job_name: str 

20 results: Dict[str, Any] 

21 message: str 

22 

23 

24def verify_cron_secret(authorization: str = Header(None)) -> bool: 

25 """ 

26 Verify cron job is authorized 

27  

28 Checks for CRON_SECRET environment variable 

29 Authorization header should be: Bearer {CRON_SECRET} 

30 """ 

31 cron_secret = os.getenv('CRON_SECRET') 

32 

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) 

36 

37 if not authorization: 

38 return False 

39 

40 if not authorization.startswith('Bearer '): 

41 return False 

42 

43 provided_secret = authorization.replace('Bearer ', '') 

44 return provided_secret == cron_secret 

45 

46 

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 

53  

54 This endpoint should be called by a cron job every hour. 

55  

56 **Authorization**: Requires `CRON_SECRET` in Authorization header as Bearer token 

57  

58 **Example**: 

59 ```bash 

60 curl -X POST https://api.alprina.com/v1/cron/abandoned-checkout \ 

61 -H "Authorization: Bearer your-cron-secret" 

62 ``` 

63  

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 

70  

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 ) 

84 

85 try: 

86 logger.info("🕐 Running abandoned checkout cron job") 

87 

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 ) 

92 

93 message = f"Processed {results['found']} users: {results['sent']} emails sent, {results['failed']} failed" 

94 logger.info(f"{message}") 

95 

96 return CronResponse( 

97 success=True, 

98 job_name="abandoned_checkout", 

99 results=results, 

100 message=message 

101 ) 

102 

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 ) 

109 

110 

111@router.get("/cron/health") 

112async def cron_health_check(): 

113 """ 

114 Health check for cron jobs 

115  

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')) 

120 

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 } 

135 

136 

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) 

144  

145 Sends a test email without checking database 

146 """ 

147 if not verify_cron_secret(authorization): 

148 raise HTTPException(status_code=401, detail="Unauthorized") 

149 

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 } 

159 

160 success = await abandoned_checkout_service.send_reminder_email(test_user) 

161 

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 ) 

173 

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 )