feat(CG5c): POST /notify/sms — Twilio SMS for Guard+-tier, spike-varsler

This commit is contained in:
chrischristiansen-glitch 2026-06-12 17:20:46 +02:00
parent c3357d38aa
commit f7e64fcea0

179
main.py
View File

@ -8,6 +8,7 @@ CG3: static-mappe serveres fra /static/*.
AUTH: /auth/login + /auth/callback for klient OAuth2 onboarding.
CG5a: POST /notify/webhook push til Slack/Teams/Chat/Discord/etc.
CG5b: POST /notify/email SendGrid digest til kunde-e-post.
CG5c: POST /notify/sms Twilio SMS, Guard+-tier, spike-varsler.
CG5-onboard: GET /onboard + POST /onboard/complete klient-onboarding wizard.
"""
@ -49,6 +50,10 @@ import sendgrid
from sendgrid.helpers.mail import Mail
AGENT_ID = "osvauco-opax"
# Guard+-tiers som får tilgang til SMS-varsler
SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"}
app = FastAPI(
title="OSVauco OPAX Agent",
description="Agent for OSVauco-OPAX platform.",
@ -168,7 +173,7 @@ class BudgetUpdateRequest(BaseModel):
budget: float
# ── NOTIFICATION MODELS (CG5a + CG5b) ────────────────────────────────────────
# ── NOTIFICATION MODELS (CG5a + CG5b + CG5c) ──────────────────────────────────
class WebhookNotifyRequest(BaseModel):
url: str = Field(..., description="Webhook-URL (Slack/Teams/Google Chat/Discord/custom)")
event: str = Field("custom", description="Event-type: spike | digest | budget | anomaly | custom")
@ -183,6 +188,31 @@ class EmailNotifyRequest(BaseModel):
body_html: Optional[str] = Field(None, description="HTML-innhold. Auto-generert fra billing-data hvis tom.")
payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere i e-post")
class SmsNotifyRequest(BaseModel):
"""
CG5c: SMS-varsel via Twilio REST API.
Kun tilgjengelig for Guard+-tier (guard | shield | enterprise).
Brukes primært til kritiske kostnadsspikes hold meldinger korte (<160 tegn).
"""
to: str = Field(
...,
description="Mottakers mobilnummer i E.164-format, f.eks. +4791234567",
pattern=r"^\+[1-9]\d{7,14}$",
)
body: str = Field(
...,
description="Meldingstekst. Maks 160 tegn anbefalt (ett SMS-segment).",
max_length=320,
)
event: str = Field(
"spike",
description="Event-type: spike | budget | anomaly | custom",
)
tier: str = Field(
...,
description="Kundens tier. Må være guard, shield eller enterprise.",
)
# ── ONBOARDING MODEL (CG5-onboard) ────────────────────────────────────────────
class OnboardCompleteRequest(BaseModel):
@ -196,13 +226,13 @@ class OnboardCompleteRequest(BaseModel):
email_to: Optional[str] = None
# ── NOTIFICATION SERVICE (CG5a + CG5b) ───────────────────────────────────────
# ── NOTIFICATION SERVICE (CG5a + CG5b + CG5c) ─────────────────────────────────
class NotificationService:
"""
Felles delivery layer for CostGuard-varsler.
CG5a: send_webhook() poster til hvilken som helst webhook-URL
CG5b: send_email() sender via SendGrid
Brukt av POST /notify/webhook og POST /notify/email.
CG5c: send_sms() sender via Twilio REST (Guard+-tier)
"""
# Event-type → emoji + standardtekst
@ -271,7 +301,6 @@ class NotificationService:
meta = self._event_meta(req.event)
subject = req.subject or f"{meta['emoji']} CostGuard: {meta['label']}{datetime.date.today()}"
# Auto-generer HTML-innhold fra billing-data hvis ikke oppgitt
if req.body_html:
html_content = req.body_html
else:
@ -293,6 +322,71 @@ class NotificationService:
print(f"[NotificationService] email error: {e}", file=sys.stderr)
return {"status": "error", "detail": str(e)}
async def send_sms(self, req: SmsNotifyRequest) -> dict:
"""
CG5c: Send SMS via Twilio REST API (ingen Twilio SDK kun httpx).
Guard+-tier påkrevd. Konfigureres via env-vars:
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER
"""
# Guard+-tier-sjekk
if req.tier.lower() not in SMS_ALLOWED_TIERS:
return {
"status": "not_allowed",
"detail": f"SMS krever Guard+-tier. Aktuæll tier: '{req.tier}'. "
f"Oppgrader på opax.vauco.no/onboard.",
}
account_sid = os.environ.get("TWILIO_ACCOUNT_SID")
auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
from_number = os.environ.get("TWILIO_FROM_NUMBER")
if not all([account_sid, auth_token, from_number]):
return {
"status": "not_configured",
"detail": "En eller flere Twilio-env-vars mangler: "
"TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER",
}
meta = self._event_meta(req.event)
# Bygg meldingstekst med event-prefix hvis den ikke allerede starter med emoji
sms_body = req.body
if not sms_body.startswith(meta["emoji"]):
sms_body = f"{meta['emoji']} CostGuard: {sms_body}"
# Twilio Messages API — form-encoded POST
twilio_url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
twilio_url,
data={"From": from_number, "To": req.to, "Body": sms_body},
auth=(account_sid, auth_token),
)
data = resp.json()
print(
f"[NotificationService] sms → {req.to} "
f"sid={data.get('sid','?')} status={data.get('status','?')}"
)
if resp.status_code >= 400:
return {
"status": "error",
"http_status": resp.status_code,
"twilio_code": data.get("code"),
"detail": data.get("message", resp.text[:200]),
}
return {
"status": "ok",
"sid": data.get("sid"),
"to": req.to,
"sms_status": data.get("status"),
"event": req.event,
}
except httpx.TimeoutException:
return {"status": "error", "detail": "Twilio timeout (>15s)"}
except Exception as e:
print(f"[NotificationService] sms error: {e}", file=sys.stderr)
return {"status": "error", "detail": str(e)}
async def _build_digest_html(self, extra_payload: Optional[dict] = None) -> str:
"""Henter billing-data og bygger HTML-digest."""
try:
@ -343,7 +437,7 @@ class NotificationService:
_notifier = NotificationService()
# ── NOTIFY ENDPOINTS (CG5a + CG5b) ───────────────────────────────────────────
# ── NOTIFY ENDPOINTS (CG5a + CG5b + CG5c) ─────────────────────────────────────
async def _notify_webhook(request: Request, req: WebhookNotifyRequest):
result = await _notifier.send_webhook(req)
@ -363,6 +457,26 @@ async def _notify_email(request: Request, req: EmailNotifyRequest):
app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"])
async def _notify_sms(request: Request, req: SmsNotifyRequest):
"""
CG5c: POST /notify/sms Guard+-tier SMS via Twilio.
HTTP-svar:
200 sendt OK
402 tier ikke Guard+ (payment required / upgrade)
503 Twilio ikke konfigurert (env-vars mangler)
500 Twilio-feil
"""
result = await _notifier.send_sms(req)
if result["status"] == "not_allowed":
return JSONResponse(status_code=402, content=result)
if result["status"] == "not_configured":
return JSONResponse(status_code=503, content=result)
status_code = 200 if result["status"] == "ok" else 500
return JSONResponse(status_code=status_code, content=result)
app.add_api_route("/notify/sms", endpoint=require_auth(_notify_sms), methods=["POST"])
# ── ONBOARDING ENDPOINTS (CG5-onboard) ───────────────────────────────────────
@app.get("/onboard")
@ -378,21 +492,19 @@ def onboard_wizard():
async def onboard_complete(req: OnboardCompleteRequest):
"""
CG5-onboard: Lagrer kundeprofil i Firestore og sender velkomstvarsler
via eksisterende POST /notify/webhook og POST /notify/email.
Ingen nye notification-endepunkter gjenbruker _notifier singleton.
via eksisterende _notifier (send_webhook + send_email).
"""
# 1. Lagre i Firestore (best-effort — feiler ikke onboarding ved DB-feil)
if db:
try:
db.collection("onboarded_customers").document(req.gcp_project).set({
"company": req.company,
"contact": req.contact,
"email": req.email,
"gcp_project": req.gcp_project,
"tier": req.tier,
"channels": req.channels,
"webhook_url": req.webhook_url,
"email_to": req.email_to,
"company": req.company,
"contact": req.contact,
"email": req.email,
"gcp_project": req.gcp_project,
"tier": req.tier,
"channels": req.channels,
"webhook_url": req.webhook_url,
"email_to": req.email_to,
"onboarded_at": firestore.SERVER_TIMESTAMP,
})
print(f"[Onboard] {req.gcp_project} lagret i Firestore")
@ -401,9 +513,8 @@ async def onboard_complete(req: OnboardCompleteRequest):
notify_results: Dict[str, Any] = {}
# 2. Webhook-velkomstmelding (via eksisterende /notify/webhook-logikk)
if req.webhook_url and "webhook" in req.channels:
wh_result = await _notifier.send_webhook(WebhookNotifyRequest(
notify_results["webhook"] = await _notifier.send_webhook(WebhookNotifyRequest(
url=req.webhook_url,
event="custom",
title=f"🎉 {req.company} er koblet til CostGuard!",
@ -412,15 +523,13 @@ async def onboard_complete(req: OnboardCompleteRequest):
f"Du mottar første kostnadsrapport innen 24 timer."
),
payload={
"bedrift": req.company,
"tier": req.tier,
"prosjekt": req.gcp_project,
"dato": datetime.date.today().isoformat(),
"bedrift": req.company,
"tier": req.tier,
"prosjekt": req.gcp_project,
"dato": datetime.date.today().isoformat(),
},
))
notify_results["webhook"] = wh_result
# 3. Velkomst-e-post (via eksisterende /notify/email-logikk)
if req.email_to and "email" in req.channels:
TIER_LABELS = {
"starter": "Starter ($499/mnd)",
@ -429,7 +538,7 @@ async def onboard_complete(req: OnboardCompleteRequest):
"enterprise": "Enterprise ($3.500+/mnd)",
}
tier_label = TIER_LABELS.get(req.tier, req.tier)
em_result = await _notifier.send_email(EmailNotifyRequest(
notify_results["email"] = await _notifier.send_email(EmailNotifyRequest(
to=req.email_to,
event="custom",
subject=f"✅ Velkommen til CostGuard — {req.company}",
@ -446,20 +555,16 @@ async def onboard_complete(req: OnboardCompleteRequest):
<table style='width:100%;border-collapse:collapse;margin:20px 0'>
<tr style='background:#efefef'>
<td style='padding:8px 12px;font-weight:700'>Bedrift</td>
<td style='padding:8px 12px'>{req.company}</td>
</tr>
<td style='padding:8px 12px'>{req.company}</td></tr>
<tr>
<td style='padding:8px 12px;font-weight:700'>GCP-prosjekt</td>
<td style='padding:8px 12px'><code>{req.gcp_project}</code></td>
</tr>
<td style='padding:8px 12px'><code>{req.gcp_project}</code></td></tr>
<tr style='background:#efefef'>
<td style='padding:8px 12px;font-weight:700'>Tier</td>
<td style='padding:8px 12px'>{tier_label}</td>
</tr>
<td style='padding:8px 12px'>{tier_label}</td></tr>
<tr>
<td style='padding:8px 12px;font-weight:700'>Kanaler</td>
<td style='padding:8px 12px'>{', '.join(req.channels)}</td>
</tr>
<td style='padding:8px 12px'>{', '.join(req.channels)}</td></tr>
</table>
<p>Du mottar din første kostnadsrapport innen 24 timer.</p>
<div style='text-align:center;margin:24px 0'>
@ -476,9 +581,7 @@ async def onboard_complete(req: OnboardCompleteRequest):
</div>
""",
))
notify_results["email"] = em_result
# 4. Intern Slack-notis til Vauco (opsjonelt — krever VAUCO_INTERNAL_WEBHOOK)
internal_wh = os.environ.get("VAUCO_INTERNAL_WEBHOOK")
if internal_wh:
try:
@ -493,7 +596,7 @@ async def onboard_complete(req: OnboardCompleteRequest):
print(f"[Onboard] Intern Slack-feil: {e}", file=sys.stderr)
return {
"status": "ok",
"status": "ok",
"customer": req.gcp_project,
"company": req.company,
"channels": req.channels,
@ -621,10 +724,6 @@ app.add_api_route("/billing/budget", endpoint=require_auth(set_budget), methods=
@app.post("/billing/email-report")
async def trigger_email_report():
"""
Legacy scheduler-endepunkt. Kaller NotificationService.send_email() internt.
Beholdes for bakoverkompatibilitet med Cloud Scheduler.
"""
result = await _notifier.send_email(EmailNotifyRequest(event="digest"))
if result["status"] == "not_configured":
return JSONResponse(status_code=200, content={"status": "not_configured", "error": result["detail"]})