feat(CG5-onboard): GET /onboard + POST /onboard/complete — kobler til /notify/webhook og /notify/email
This commit is contained in:
parent
50490b071d
commit
c3357d38aa
153
main.py
153
main.py
|
|
@ -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 nå 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():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user