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})
|
||||
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
|
||||
|
||||
|
|
|
|||
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 "/security", SecurityLive, :index
|
||||
live "/support", SupportLive, :index
|
||||
live "/emergency", EmergencyLive, :index
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue