diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/client.tsx index 1df67cf23..280f84eb3 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/client.tsx @@ -1,10 +1,7 @@ "use client"; -import { Add } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { formatDistanceStrict } from "date-fns"; -import { toast } from "sonner"; import { DetailActions, @@ -22,21 +19,18 @@ import { DetailTitleRow, } from "@/components/content/detail"; import { SectionGroup } from "@/components/content/section"; -import { FormCardGroup } from "@/components/forms/form-card"; -import { FormSheetWithDirtyProtection } from "@/components/forms/form-sheet"; -import { FormMaintenanceUpdateCard } from "@/components/forms/maintenance-update/card"; -import { FormSheetMaintenanceUpdate } from "@/components/forms/maintenance-update/sheet"; +import { Timeline } from "@/components/content/timeline"; import { MaintenanceActions } from "@/components/maintenances/maintenance-actions"; import { MaintenanceComponents } from "@/components/maintenances/maintenance-components"; import { MaintenanceComposer } from "@/components/maintenances/maintenance-composer"; import { MaintenanceProperties } from "@/components/maintenances/maintenance-properties"; -import { useInvalidateMaintenance } from "@/components/maintenances/use-invalidate-maintenance"; +import { MaintenanceUpdateComposer } from "@/components/maintenances/maintenance-update-composer"; +import { MaintenanceUpdateTimelineItem } from "@/components/maintenances/maintenance-update-timeline"; import { useMaintenanceStatus } from "@/components/maintenances/use-maintenance-status"; import { useUpdateMaintenance } from "@/components/maintenances/use-update-maintenance"; import { Notifications } from "@/components/status-pages/notifications"; import { getPageUrl } from "@/data/status-pages.client"; import { useTRPC } from "@/lib/trpc/client"; -import { errorMessage } from "@/lib/trpc/error"; export function Client({ id, pageId }: { id: number; pageId: number }) { const trpc = useTRPC(); @@ -44,39 +38,8 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { trpc.maintenance.get.queryOptions({ id }), ); const { data: page } = useQuery(trpc.page.get.queryOptions({ id: pageId })); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const rename = useUpdateMaintenance(id); - const invalidate = useInvalidateMaintenance(id); - const onError = (error: unknown) => { - toast.error(errorMessage(error, "Failed to save")); - }; - const notify = useMutation( - trpc.subscriberNotification.maintenanceUpdate.mutationOptions({ - onError: (error) => { - toast.error(errorMessage(error, "Failed to notify subscribers")); - }, - }), - ); - const createUpdate = useMutation( - trpc.maintenance.createUpdate.mutationOptions({ - onSuccess: async (update) => { - if (update?.notifySubscribers) notify.mutate({ id: update.id }); - await invalidate(); - }, - onError, - }), - ); - const editUpdate = useMutation( - trpc.maintenance.updateUpdate.mutationOptions({ - onSuccess: invalidate, - onError, - }), - ); - const deleteUpdate = useMutation( - trpc.maintenance.deleteUpdate.mutationOptions({ - onSuccess: invalidate, - onError, - }), - ); const status = useMaintenanceStatus( maintenance ?? { from: new Date(0), to: new Date(0) }, ); @@ -84,9 +47,11 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { if (!maintenance || !page) return null; const publicUrl = `${getPageUrl(page)}/events/maintenance/${maintenance.id}`; + const canNotify = workspace?.limits["status-subscribers"] === true; const updates = [...maintenance.updates].sort( (a, b) => b.date.getTime() - a.date.getTime() || b.id - a.id, ); + const latest = updates[0]; return ( @@ -120,6 +85,16 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { {formatDistanceStrict(maintenance.from, maintenance.to)} + {updates.length ? ( + + {updates.length} {updates.length === 1 ? "update" : "updates"} + + ) : null} + {latest ? ( + + Last update + + ) : null} @@ -130,42 +105,20 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { Updates - - {updates.map((update, index) => ( - - { - await editUpdate.mutateAsync({ - id: update.id, - message: values.message, - date: values.date, - }); - }} - onDelete={async () => { - await deleteUpdate.mutateAsync({ id: update.id }); - }} - /> - + + + {updates.map((update, i) => ( + ))} - - { - await createUpdate.mutateAsync({ - maintenanceId: maintenance.id, - message: values.message, - date: values.date, - notifySubscribers: values.notifySubscribers, - }); - }} - > - - + diff --git a/apps/dashboard/src/components/forms/maintenance-update/card.tsx b/apps/dashboard/src/components/forms/maintenance-update/card.tsx deleted file mode 100644 index 46b2aa02f..000000000 --- a/apps/dashboard/src/components/forms/maintenance-update/card.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@openstatus/api"; -import { Button } from "@openstatus/ui/components/ui/button"; - -import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; -import { - FormCard, - FormCardFooter, - FormCardHeader, - FormCardTitle, -} from "@/components/forms/form-card"; -import { - FormMaintenanceUpdate, - type FormValues, -} from "@/components/forms/maintenance-update/form"; - -type MaintenanceUpdate = RouterOutputs["maintenance"]["get"]["updates"][number]; - -export function FormMaintenanceUpdateCard({ - index, - total, - update, - onSubmit, - onDelete, -}: { - index: number; - total: number; - update: MaintenanceUpdate; - onSubmit: (values: FormValues) => Promise; - onDelete: () => Promise; -}) { - const formId = `maintenance-update-${update.id}`; - const title = `Maintenance Update #${total - index}`; - - return ( - - - {title} - - - - - - - - - - ); -} diff --git a/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx b/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx index 5a5f35e4d..314a53b8b 100644 --- a/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx +++ b/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx @@ -22,20 +22,35 @@ export function FormSheetMaintenanceUpdate({ children, defaultValues, onSubmit, + open: controlledOpen, + onOpenChange, }: Omit, "onSubmit"> & { defaultValues?: Partial; onSubmit: (values: FormValues) => Promise; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = (next: boolean) => { + if (isControlled) { + onOpenChange?.(next); + } else { + setInternalOpen(next); + } + }; return ( - {children} + {children ? ( + {children} + ) : null} Maintenance Update - Post a dated update to this maintenance. + Edit the message or date of this update. @@ -44,7 +59,6 @@ export function FormSheetMaintenanceUpdate({ id="maintenance-update-form" className="my-4" defaultValues={defaultValues} - showNotifySubscribers onSubmit={async (values) => { await onSubmit(values); setOpen(false); diff --git a/apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx b/apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx new file mode 100644 index 000000000..8660dc428 --- /dev/null +++ b/apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx @@ -0,0 +1,131 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; +import { Label } from "@openstatus/ui/components/ui/label"; +import { personName } from "@openstatus/utils"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { DateTimePicker } from "@/components/common/date-time-picker"; +import { + Composer, + ComposerFooter, + ComposerHeader, + ComposerPreview, + ComposerTextarea, +} from "@/components/content/composer"; +import { TimelineAvatar, TimelineItem } from "@/components/content/timeline"; +import { useTRPC } from "@/lib/trpc/client"; +import { errorMessage } from "@/lib/trpc/error"; + +import { usePublishMaintenanceUpdate } from "./use-publish-maintenance-update"; + +type Maintenance = NonNullable; + +/** Posts a dated note on the maintenance timeline; the announcement stays as is. */ +export function MaintenanceUpdateComposer({ + maintenance, + canNotify, +}: { + maintenance: Maintenance; + /** Whether the plan includes subscriber notifications. */ + canNotify: boolean; +}) { + const trpc = useTRPC(); + const { data: user } = useQuery(trpc.user.get.queryOptions()); + const [message, setMessage] = useState(""); + const [notifyChecked, setNotifyChecked] = useState(true); + // null = now, resolved at publish time so an open composer never backdates. + const [date, setDate] = useState(null); + // shown while `date` is null; refreshed on reset + const [now, setNow] = useState(() => new Date()); + + const publish = usePublishMaintenanceUpdate(maintenance.id); + const notify = canNotify && notifyChecked; + const disabled = publish.isPending || !message.trim(); + + function reset() { + setMessage(""); + setDate(null); + setNow(new Date()); + } + + async function submit() { + if (disabled) return; + const promise = publish.publish({ + message: message.trim(), + date: date ?? new Date(), + notifySubscribers: notify, + }); + toast.promise(promise, { + loading: "Publishing...", + success: "Update published", + error: (error) => errorMessage(error, "Failed to publish"), + }); + await promise; + reset(); + } + + return ( + + + + + setMessage(e.target.value)} + onSubmit={() => submit().catch(console.error)} + /> + + +
+ Posted + +
+
+
+ setNotifyChecked(value === true)} + /> + +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/maintenances/maintenance-update-timeline.tsx b/apps/dashboard/src/components/maintenances/maintenance-update-timeline.tsx new file mode 100644 index 000000000..f6fae2163 --- /dev/null +++ b/apps/dashboard/src/components/maintenances/maintenance-update-timeline.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Maintenance as MaintenanceIcon } from "@openstatus/icons"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; + +import { ProcessMessage } from "@/components/content/process-message"; +import { + TimelineActor, + TimelineBody, + TimelineContent, + TimelineHeader, + TimelineIndicator, + TimelineItem, + TimelineMeta, + TimelineTime, + TimelineTitle, +} from "@/components/content/timeline"; +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { FormSheetMaintenanceUpdate } from "@/components/forms/maintenance-update/sheet"; +import { distinctEditor } from "@/data/attribution.client"; +import { getActions } from "@/data/maintenances.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { useInvalidateMaintenance } from "./use-invalidate-maintenance"; + +type Maintenance = NonNullable; +type MaintenanceUpdate = Maintenance["updates"][number]; + +export function MaintenanceUpdateTimelineItem({ + maintenance, + update, + index, +}: { + maintenance: Maintenance; + update: MaintenanceUpdate; + /** 1-based, counted from the oldest update. */ + index: number; +}) { + const trpc = useTRPC(); + const [editing, setEditing] = useState(false); + const invalidate = useInvalidateMaintenance(maintenance.id); + const edit = useMutation( + trpc.maintenance.updateUpdate.mutationOptions({ onSuccess: invalidate }), + ); + const remove = useMutation( + trpc.maintenance.deleteUpdate.mutationOptions({ onSuccess: invalidate }), + ); + const author = update.createdByUser; + const editor = distinctEditor(update); + + return ( + + + + + + + + Update + {author ? : null} + {editor ? ( + + edited by + + ) : null} + + + + + + +
+ #{index} + setEditing(true) })} + deleteAction={{ + description: `Permanently remove update #${index} from the status page.`, + submitAction: async () => { + await remove.mutateAsync({ id: update.id }); + }, + }} + /> +
+
+ { + await edit.mutateAsync({ + id: update.id, + message: values.message, + date: values.date, + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts b/apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts new file mode 100644 index 000000000..291828fe8 --- /dev/null +++ b/apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useTRPC } from "@/lib/trpc/client"; +import { errorMessage } from "@/lib/trpc/error"; + +import { useInvalidateMaintenance } from "./use-invalidate-maintenance"; + +/** + * Posts a maintenance update, notifies subscribers when asked, then refetches. + * The update is persisted before notifying, so a notification failure is + * reported on its own and never rejects the publish. + */ +export function usePublishMaintenanceUpdate(maintenanceId: number) { + const trpc = useTRPC(); + const invalidate = useInvalidateMaintenance(maintenanceId); + const create = useMutation(trpc.maintenance.createUpdate.mutationOptions()); + const notify = useMutation( + trpc.subscriberNotification.maintenanceUpdate.mutationOptions(), + ); + + return { + isPending: create.isPending || notify.isPending, + async publish(input: { + message: string; + date: Date; + notifySubscribers: boolean; + }) { + const update = await create.mutateAsync({ maintenanceId, ...input }); + if (update && input.notifySubscribers) { + const fallback = "Update published, but subscribers were not notified"; + try { + // resolves with success=false when the plan lacks subscribers + const result = await notify.mutateAsync({ id: update.id }); + if (!result.success) toast.error(fallback); + } catch (error) { + toast.error(errorMessage(error, fallback)); + } + } + await invalidate(); + }, + }; +} diff --git a/packages/services/src/maintenance/index.ts b/packages/services/src/maintenance/index.ts index 6d87d57fc..e38fef64f 100644 --- a/packages/services/src/maintenance/index.ts +++ b/packages/services/src/maintenance/index.ts @@ -7,6 +7,7 @@ export { getMaintenance, listMaintenances, type ListMaintenancesResult, + type MaintenanceUpdateWithRelations, type MaintenanceWithRelations, } from "./list"; export { notifyMaintenance } from "./notify"; diff --git a/packages/services/src/maintenance/list.ts b/packages/services/src/maintenance/list.ts index 040da8a2b..711b82d20 100644 --- a/packages/services/src/maintenance/list.ts +++ b/packages/services/src/maintenance/list.ts @@ -40,8 +40,15 @@ function periodToSince(period: MaintenanceListPeriod): Date { } } +type Attributed = { + createdByUser: AttributedUserDetail | null; + updatedByUser: AttributedUserDetail | null; +}; + +export type MaintenanceUpdateWithRelations = MaintenanceUpdate & Attributed; + export type MaintenanceWithRelations = Maintenance & { - updates: MaintenanceUpdate[]; + updates: MaintenanceUpdateWithRelations[]; pageComponents: PageComponent[]; pageComponentIds: number[]; createdByUser: AttributedUserDetail | null; @@ -71,12 +78,6 @@ async function enrichMaintenancesBatch( .where(inArray(maintenanceUpdate.maintenanceId, ids)) .orderBy(desc(maintenanceUpdate.date), desc(maintenanceUpdate.id)) .all(); - const updatesByMaintenance = new Map(); - for (const update of allUpdates) { - const existing = updatesByMaintenance.get(update.maintenanceId); - if (existing) existing.push(update); - else updatesByMaintenance.set(update.maintenanceId, [update]); - } // Explicit column selection (not `select()`) keeps the row shape in our // hands instead of relying on drizzle's auto-derived `row.` @@ -95,10 +96,30 @@ async function enrichMaintenancesBatch( .where(inArray(maintenancesToPageComponents.maintenanceId, ids)) .all(); - const users = await loadAttributedUsers( - db, - rows.flatMap((r) => [r.createdBy, r.updatedBy]), - ); + const users = await loadAttributedUsers(db, [ + ...rows.flatMap((r) => [r.createdBy, r.updatedBy]), + ...allUpdates.flatMap((u) => [u.createdBy, u.updatedBy]), + ]); + const attributed = (row: { + createdBy: number | null; + updatedBy: number | null; + }): Attributed => ({ + createdByUser: + row.createdBy != null ? (users.get(row.createdBy) ?? null) : null, + updatedByUser: + row.updatedBy != null ? (users.get(row.updatedBy) ?? null) : null, + }); + + const updatesByMaintenance = new Map< + number, + MaintenanceUpdateWithRelations[] + >(); + for (const update of allUpdates) { + const withUsers = { ...update, ...attributed(update) }; + const existing = updatesByMaintenance.get(update.maintenanceId); + if (existing) existing.push(withUsers); + else updatesByMaintenance.set(update.maintenanceId, [withUsers]); + } const componentsByMaintenance = new Map(); for (const row of assocRows) { @@ -115,10 +136,7 @@ async function enrichMaintenancesBatch( updates: updatesByMaintenance.get(r.id) ?? [], pageComponents: components, pageComponentIds: components.map((c) => c.id), - createdByUser: - r.createdBy != null ? (users.get(r.createdBy) ?? null) : null, - updatedByUser: - r.updatedBy != null ? (users.get(r.updatedBy) ?? null) : null, + ...attributed(r), }; }); }