feat(CG5c): POST /notify/sms — Twilio SMS for Guard+-tier, spike-varsler
This commit is contained in:
parent
c3357d38aa
commit
f7e64fcea0
179
main.py
179
main.py
|
|
@ -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"]})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user