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:
parent
ce1c475bcc
commit
7d65dd1be4
4 changed files with 251 additions and 0 deletions
Binary file not shown.
|
|
@ -26,6 +26,19 @@ defmodule CentralcloudCore.HostBill do
|
||||||
call("getInvoices", %{clientid: client_id})
|
call("getInvoices", %{clientid: client_id})
|
||||||
end
|
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
|
# Private
|
||||||
|
|
||||||
|
|
|
||||||
237
apps/centralcloud_my/lib/centralcloud_my/live/emergency_live.ex
Normal file
237
apps/centralcloud_my/lib/centralcloud_my/live/emergency_live.ex
Normal 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
|
||||||
|
|
@ -47,5 +47,6 @@ defmodule CentralcloudMy.Router do
|
||||||
live "/billing", BillingLive, :index
|
live "/billing", BillingLive, :index
|
||||||
live "/security", SecurityLive, :index
|
live "/security", SecurityLive, :index
|
||||||
live "/support", SupportLive, :index
|
live "/support", SupportLive, :index
|
||||||
|
live "/emergency", EmergencyLive, :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue