Add emergency page and HostBill ticket creation

- New /emergency page at my.centralcloud.com/emergency
- Customers can submit emergency tickets that page on-call engineer
- HostBill API client now supports addTicket call
- Emergency form creates ticket via HostBill then notifies ops backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-15 07:07:51 +02:00
parent ce1c475bcc
commit 7d65dd1be4
4 changed files with 251 additions and 0 deletions

View file

@ -26,6 +26,19 @@ defmodule CentralcloudCore.HostBill do
call("getInvoices", %{clientid: client_id})
end
@doc "Create a ticket via HostBill API."
@spec add_ticket(integer(), String.t(), String.t(), integer(), String.t()) ::
{:ok, map()} | {:error, term()}
def add_ticket(client_id, subject, message, department_id \\ 1, priority \\ "High") do
call("addTicket", %{
clientid: client_id,
subject: subject,
message: message,
departmentid: department_id,
priority: priority
})
end
# ---------------------------------------------------------------------------
# Private

View file

@ -0,0 +1,237 @@
defmodule CentralcloudMy.EmergencyLive do
@moduledoc """
Customer emergency page at /emergency.
Allows authenticated customers to raise an emergency ticket that immediately
creates an incident in ops and pages the on-call engineer.
"""
use Phoenix.LiveView
alias CentralcloudCore.HostBill
require Logger
def render(assigns) do
~H"""
<div style="max-width: 640px; margin: 0 auto; padding: 2rem 1rem;">
<div style="background: #dc2626; color: white; padding: 1rem 1.5rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
<h2 style="font-size: 1.5rem; margin: 0;">🚨 Emergency Support</h2>
<p style="margin: 0.5rem 0 0 0; opacity: 0.9;">
This will immediately alert our on-call engineer. Only use for outages or security incidents.
</p>
</div>
<%= if @submitted do %>
<div style="background: #166534; color: white; padding: 1.5rem; border-radius: 0.5rem; text-align: center;">
<h3 style="margin: 0 0 0.5rem 0;"> Emergency reported</h3>
<p style="margin: 0;">Our on-call engineer has been paged. Ticket reference: <strong><%= @ticket_ref %></strong></p>
<p style="margin: 1rem 0 0 0; font-size: 0.875rem; opacity: 0.8;">
You will receive updates via email and push notification.
</p>
</div>
<% else %>
<form phx-submit="submit" style="display: flex; flex-direction: column; gap: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.25rem;">Subject</label>
<input
type="text"
name="subject"
value={@subject}
phx-change="validate"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #334155; border-radius: 0.375rem; background: #0f172a; color: #e2e8f0;"
placeholder="e.g. Website is completely down"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.25rem;">Description</label>
<textarea
name="body"
phx-change="validate"
required
rows="5"
style="width: 100%; padding: 0.5rem; border: 1px solid #334155; border-radius: 0.375rem; background: #0f172a; color: #e2e8f0; resize: vertical;"
placeholder="Describe the issue, when it started, and what you've tried..."
><%= @body %></textarea>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.25rem;">Affected Service (optional)</label>
<select
name="service_id"
phx-change="validate"
style="width: 100%; padding: 0.5rem; border: 1px solid #334155; border-radius: 0.375rem; background: #0f172a; color: #e2e8f0;"
>
<option value="">-- Select service --</option>
<%= for svc <- @services do %>
<option value={svc.id} selected={svc.id == @service_id}><%= svc.name %></option>
<% end %>
</select>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; background: #450a0a; border: 1px solid #7f1d1d; border-radius: 0.375rem;">
<input
type="checkbox"
name="confirm"
id="confirm"
required
style="width: 1rem; height: 1rem;"
/>
<label for="confirm" style="margin: 0; font-size: 0.875rem;">
I confirm this is an emergency requiring immediate attention
</label>
</div>
<button
type="submit"
disabled={not @valid}
style={if @valid, do: "padding: 0.75rem 1.5rem; background: #dc2626; color: white; border: none; border-radius: 0.375rem; font-weight: 600; cursor: pointer;", else: "padding: 0.75rem 1.5rem; background: #7f1d1d; color: #fca5a5; border: none; border-radius: 0.375rem; font-weight: 600; cursor: not-allowed;"}
>
🚨 Page On-Call Engineer
</button>
<%= if @error do %>
<div style="color: #fca5a5; background: #450a0a; padding: 0.75rem; border-radius: 0.375rem;">
<%= @error %>
</div>
<% end %>
</form>
<% end %>
</div>
"""
end
def mount(_params, _session, socket) do
services = fetch_services(socket.assigns[:current_user])
{:ok,
assign(socket,
subject: "",
body: "",
service_id: "",
services: services,
valid: false,
submitted: false,
ticket_ref: nil,
error: nil
)}
end
def handle_event("validate", params, socket) do
subject = params["subject"] || ""
body = params["body"] || ""
confirm = params["confirm"] == "true"
valid =
String.trim(subject) != "" and
String.trim(body) != "" and
confirm
{:noreply,
assign(socket,
subject: subject,
body: body,
service_id: params["service_id"] || "",
valid: valid,
error: nil
)}
end
def handle_event("submit", params, socket) do
user = socket.assigns[:current_user]
subject = String.trim(params["subject"] || "")
body = String.trim(params["body"] || "")
service_id = params["service_id"] || ""
if subject == "" or body == "" do
{:noreply, assign(socket, error: "Subject and description are required.")}
else
client_id = user[:hostbill_client_id] || user["hostbill_client_id"]
# Create emergency ticket in HostBill
ticket_result =
HostBill.call("addTicket", %{
clientid: client_id,
subject: "[EMERGENCY] #{subject}",
message: body,
departmentid: 1,
priority: "High"
})
case ticket_result do
{:ok, %{"ticketid" => ticket_id}} ->
# Notify ops backend via webhook
notify_ops_emergency(ticket_id, subject, body, client_id, service_id)
{:noreply,
assign(socket,
submitted: true,
ticket_ref: ticket_id,
error: nil
)}
{:ok, _} ->
{:noreply, assign(socket, error: "Ticket created but response format unexpected.")}
{:error, reason} ->
Logger.error("[EmergencyLive] Failed to create HostBill ticket: #{inspect(reason)}")
{:noreply, assign(socket, error: "Failed to create ticket. Please try again or call directly.")}
end
end
end
defp fetch_services(nil), do: []
defp fetch_services(user) do
client_id = user[:hostbill_client_id] || user["hostbill_client_id"]
if client_id do
case HostBill.get_client_services(client_id) do
{:ok, services} when is_list(services) ->
Enum.map(services, fn s ->
%{id: s["id"] || s["service_id"] || "", name: s["name"] || s["domain"] || "Service"}
end)
|> Enum.reject(&(&1.id == ""))
_ ->
[]
end
else
[]
end
end
defp notify_ops_emergency(ticket_id, subject, body, client_id, service_id) do
ops_url = System.get_env("OPS_URL", "https://ops.centralcloud.com")
payload = %{
event: "emergency_ticket",
details: %{
ticket_id: ticket_id,
subject: subject,
body: body,
client_id: client_id,
service_id: service_id,
source: "my.centralcloud.com"
}
}
# Fire webhook to ops backend
Task.start(fn ->
secret = System.get_env("HOSTBILL_WEBHOOK_SECRET", "")
raw_body = Jason.encode!(payload)
signature = :crypto.mac(:hmac, :sha256, secret, raw_body) |> Base.encode16(case: :lower)
Req.post("#{ops_url}/api/hostbill/events",
body: raw_body,
headers: [
{"content-type", "application/json"},
{"x-centralcloud-signature", signature}
],
retry: false
)
end)
end
end

View file

@ -47,5 +47,6 @@ defmodule CentralcloudMy.Router do
live "/billing", BillingLive, :index
live "/security", SecurityLive, :index
live "/support", SupportLive, :index
live "/emergency", EmergencyLive, :index
end
end