diff --git a/slack-app-manifest.json b/slack-app-manifest.json new file mode 100644 index 0000000..c29abae --- /dev/null +++ b/slack-app-manifest.json @@ -0,0 +1,28 @@ +{ + "display_information": { + "name": "Site Status", + "description": "Posts alerts when monitored sites go down or recover.", + "background_color": "#C4453C" + }, + "features": { + "bot_user": { + "display_name": "Site Status", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "incoming-webhook" + ] + } + }, + "settings": { + "incoming_webhooks": { + "incoming_webhooks_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts index 4a174b8..aa88187 100644 --- a/src/__tests__/notifications.test.ts +++ b/src/__tests__/notifications.test.ts @@ -91,7 +91,7 @@ describe("sendIncidentSlack", () => { }) test("posts to all Slack webhooks with correct content", async () => { - mockFetch.mockResolvedValue({ ok: true }) + mockFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve("") }) await sendIncidentSlack({ siteName: "Main Website", @@ -115,7 +115,7 @@ describe("sendIncidentSlack", () => { }) test("sends to multiple webhooks", async () => { - mockFetch.mockResolvedValue({ ok: true }) + mockFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve("") }) await sendIncidentSlack({ siteName: "Main Website", @@ -172,7 +172,7 @@ describe("sendIncidentSlack", () => { test("logs error when webhook returns non-ok status", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - mockFetch.mockResolvedValue({ ok: false, status: 403 }) + mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("Forbidden") }) await sendIncidentSlack({ siteName: "Main Website", @@ -187,7 +187,7 @@ describe("sendIncidentSlack", () => { }) test("omits error line when error is null", async () => { - mockFetch.mockResolvedValue({ ok: true }) + mockFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve("") }) await sendIncidentSlack({ siteName: "Main Website", diff --git a/src/app/incidents/[id]/actions.ts b/src/app/incidents/[id]/actions.ts index c30678b..814723a 100644 --- a/src/app/incidents/[id]/actions.ts +++ b/src/app/incidents/[id]/actions.ts @@ -15,7 +15,7 @@ export async function resolveIncident(incidentId: string) { redirect("/login") } - await supabase + const { error } = await supabase .from("incidents") .update({ status: "resolved", @@ -23,6 +23,8 @@ export async function resolveIncident(incidentId: string) { }) .eq("id", incidentId) + if (error) return { error: `Failed to resolve incident: ${error.message}` } + revalidatePath(`/incidents/${incidentId}`) revalidatePath("/") } diff --git a/src/app/incidents/[id]/page.tsx b/src/app/incidents/[id]/page.tsx index d708242..9fc4900 100644 --- a/src/app/incidents/[id]/page.tsx +++ b/src/app/incidents/[id]/page.tsx @@ -5,6 +5,7 @@ import type { Incident, Site, Check } from "@/lib/supabase/types" import CheckLogTable from "@/components/CheckLogTable" import { LocalDateTime } from "@/components/LocalTime" import { resolveIncident } from "./actions" +import ActionForm from "@/components/ActionForm" export const revalidate = 0 @@ -140,7 +141,7 @@ export default async function IncidentDetailPage({ {isOpen && user && (
Click here to set your password and create your account
`.trim(), }) - } catch (err) { - console.error("Failed to send invitation email:", err) + } catch { + revalidatePath("/settings") + return { error: "Invitation created but failed to send email. The invite link is still valid." } } revalidatePath("/settings") @@ -63,6 +64,7 @@ export async function revokeInvitation(invitationId: string) { if (!user) redirect("/login") - await supabase.from("invitations").delete().eq("id", invitationId) + const { error } = await supabase.from("invitations").delete().eq("id", invitationId) + if (error) return { error: `Failed to revoke invitation: ${error.message}` } revalidatePath("/settings") } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e87ab8c..82fd070 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -4,6 +4,7 @@ import type { Contact, Invitation } from "@/lib/supabase/types" import { deleteContact } from "./actions" import AddContactForm from "@/components/AddContactForm" import { sendInvitation, revokeInvitation } from "./invite-actions" +import ActionForm from "@/components/ActionForm" import Link from "next/link" export const revalidate = 0 @@ -82,7 +83,7 @@ export default async function SettingsPage() { {contact.type === "slack" ? (contact.label || "Slack webhook") : contact.email} - + )) )} @@ -133,7 +134,7 @@ export default async function SettingsPage() { {invitation.email} - + )) )} - + ) diff --git a/src/app/sites/actions.ts b/src/app/sites/actions.ts index 8e4bb95..443357a 100644 --- a/src/app/sites/actions.ts +++ b/src/app/sites/actions.ts @@ -15,7 +15,7 @@ export async function addSite(formData: FormData) { const name = (formData.get("name") as string)?.trim() const url = (formData.get("url") as string)?.trim() - if (!name || !url) return + if (!name || !url) return { error: "Name and URL are both required." } const { data: maxRow } = await supabase .from("sites") @@ -26,7 +26,8 @@ export async function addSite(formData: FormData) { const position = (maxRow?.position ?? -1) + 1 - await supabase.from("sites").insert({ name, url, position }) + const { error } = await supabase.from("sites").insert({ name, url, position }) + if (error) return { error: `Failed to add site: ${error.message}` } revalidatePath("/") } @@ -41,9 +42,10 @@ export async function editSite(siteId: string, formData: FormData) { const name = (formData.get("name") as string)?.trim() const url = (formData.get("url") as string)?.trim() - if (!name || !url) return + if (!name || !url) return { error: "Name and URL are both required." } - await supabase.from("sites").update({ name, url }).eq("id", siteId) + const { error } = await supabase.from("sites").update({ name, url }).eq("id", siteId) + if (error) return { error: `Failed to update site: ${error.message}` } revalidatePath("/") revalidatePath(`/sites/${siteId}`) } @@ -56,7 +58,8 @@ export async function deleteSite(siteId: string) { if (!user) redirect("/login") - await supabase.from("sites").delete().eq("id", siteId) + const { error } = await supabase.from("sites").delete().eq("id", siteId) + if (error) return { error: `Failed to delete site: ${error.message}` } revalidatePath("/") } @@ -69,10 +72,11 @@ export async function reorderSites(orderedIds: string[]) { if (!user) redirect("/login") for (let i = 0; i < orderedIds.length; i++) { - await supabase + const { error } = await supabase .from("sites") .update({ position: i }) .eq("id", orderedIds[i]) + if (error) return { error: `Failed to reorder sites: ${error.message}` } } revalidatePath("/") diff --git a/src/components/ActionForm.tsx b/src/components/ActionForm.tsx new file mode 100644 index 0000000..8659c6f --- /dev/null +++ b/src/components/ActionForm.tsx @@ -0,0 +1,26 @@ +"use client" + +import { useErrorDialog } from "@/components/ErrorDialog" + +export default function ActionForm({ + action, + children, + ...props +}: { + action: (formData: FormData) => Promise<{ error: string } | undefined> + children: React.ReactNode +} & Omit