feat(CG5-onboard): GET /onboard + POST /onboard/complete — kobler til /notify/webhook og /notify/email

This commit is contained in:
chrischristiansen-glitch 2026-06-12 17:14:26 +02:00
parent 50490b071d
commit c3357d38aa

153
main.py
View File

@ -8,6 +8,7 @@ CG3: static-mappe serveres fra /static/*.
AUTH: /auth/login + /auth/callback for klient OAuth2 onboarding. AUTH: /auth/login + /auth/callback for klient OAuth2 onboarding.
CG5a: POST /notify/webhook push til Slack/Teams/Chat/Discord/etc. CG5a: POST /notify/webhook push til Slack/Teams/Chat/Discord/etc.
CG5b: POST /notify/email SendGrid digest til kunde-e-post. CG5b: POST /notify/email SendGrid digest til kunde-e-post.
CG5-onboard: GET /onboard + POST /onboard/complete klient-onboarding wizard.
""" """
import os import os
@ -56,7 +57,7 @@ app = FastAPI(
# Paths exempt from IAP enforcement (health/readiness probes reach Cloud Run # Paths exempt from IAP enforcement (health/readiness probes reach Cloud Run
# directly without the IAP-injected x-goog-authenticated-user-email header). # directly without the IAP-injected x-goog-authenticated-user-email header).
IAP_EXEMPT_PATHS = {"/health", "/healthz", "/readiness", "/liveness"} IAP_EXEMPT_PATHS = {"/health", "/healthz", "/readiness", "/liveness", "/onboard"}
@app.middleware("http") @app.middleware("http")
async def require_iap(request: Request, call_next): async def require_iap(request: Request, call_next):
@ -183,6 +184,18 @@ class EmailNotifyRequest(BaseModel):
payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere i e-post") payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere i e-post")
# ── ONBOARDING MODEL (CG5-onboard) ────────────────────────────────────────────
class OnboardCompleteRequest(BaseModel):
company: str
contact: str
email: str
gcp_project: str
tier: str
channels: List[str]
webhook_url: Optional[str] = None
email_to: Optional[str] = None
# ── NOTIFICATION SERVICE (CG5a + CG5b) ─────────────────────────────────────── # ── NOTIFICATION SERVICE (CG5a + CG5b) ───────────────────────────────────────
class NotificationService: class NotificationService:
""" """
@ -350,6 +363,144 @@ async def _notify_email(request: Request, req: EmailNotifyRequest):
app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"]) app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"])
# ── ONBOARDING ENDPOINTS (CG5-onboard) ───────────────────────────────────────
@app.get("/onboard")
def onboard_wizard():
"""
CG5-onboard: Server onboarding-wizard HTML.
IAP-exempt kunder når denne uten eksisterende sesjon.
"""
return FileResponse("static/onboard.html")
@app.post("/onboard/complete")
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.
"""
# 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,
"onboarded_at": firestore.SERVER_TIMESTAMP,
})
print(f"[Onboard] {req.gcp_project} lagret i Firestore")
except Exception as e:
print(f"[Onboard] Firestore-feil (ikke kritisk): {e}", file=sys.stderr)
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(
url=req.webhook_url,
event="custom",
title=f"🎉 {req.company} er koblet til CostGuard!",
body=(
f"Hei {req.contact}! CostGuard overvåker nå *{req.gcp_project}*. "
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(),
},
))
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)",
"guard": "Guard ($999/mnd)",
"shield": "Shield ($1.999/mnd)",
"enterprise": "Enterprise ($3.500+/mnd)",
}
tier_label = TIER_LABELS.get(req.tier, req.tier)
em_result = await _notifier.send_email(EmailNotifyRequest(
to=req.email_to,
event="custom",
subject=f"✅ Velkommen til CostGuard — {req.company}",
body_html=f"""
<div style='font-family:sans-serif;max-width:600px;margin:0 auto;padding:32px'>
<div style='background:linear-gradient(135deg,#7c5cff,#00d4a8);padding:28px;
border-radius:12px 12px 0 0;text-align:center'>
<h1 style='color:#fff;margin:0;font-size:26px'>🎉 Velkommen til CostGuard!</h1>
</div>
<div style='background:#f9f9f9;padding:28px;border-radius:0 0 12px 12px'>
<p style='font-size:16px'>Hei <strong>{req.contact}</strong>,</p>
<p>CostGuard overvåker ditt GCP-prosjekt og vil varsle deg ved kostnadsspikes,
anomalier og budsjettoverskridelser.</p>
<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>
<tr>
<td style='padding:8px 12px;font-weight:700'>GCP-prosjekt</td>
<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>
<tr>
<td style='padding:8px 12px;font-weight:700'>Kanaler</td>
<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'>
<a href='https://opax.vauco.no/static/costguard-dashboard.html'
style='background:linear-gradient(135deg,#7c5cff,#00d4a8);
color:#050509;padding:12px 28px;border-radius:99px;
text-decoration:none;font-weight:700;font-size:14px'>
Åpne dashboard
</a>
</div>
<hr style='border:none;border-top:1px solid #ddd'>
<p style='font-size:12px;color:#999'>CostGuard by Vauco AS · opax.vauco.no</p>
</div>
</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:
await _notifier.send_webhook(WebhookNotifyRequest(
url=internal_wh,
event="custom",
title="🆕 Ny CostGuard-kunde onboardet",
body=f"{req.company} ({req.gcp_project}) — tier: {req.tier}",
payload={"kontakt": req.contact, "e-post": req.email, "kanaler": ", ".join(req.channels)},
))
except Exception as e:
print(f"[Onboard] Intern Slack-feil: {e}", file=sys.stderr)
return {
"status": "ok",
"customer": req.gcp_project,
"company": req.company,
"channels": req.channels,
"notify": notify_results,
}
# ── ROOT LANDING PAGE ─────────────────────────────────────────────────── # ── ROOT LANDING PAGE ───────────────────────────────────────────────────
@app.get("/") @app.get("/")
def root(): def root():