feat: add board webhook configuration and payload models

This commit is contained in:
Abhimanyu Saharan
2026-02-13 00:31:32 +05:30
parent afc8de3c24
commit 2e4739300c
31 changed files with 3801 additions and 158 deletions

View File

@@ -113,6 +113,7 @@ It will:
When changing UI intended to be mobile-ready, validate in Chrome (or similar) using the device toolbar at common widths (e.g. **320px**, **375px**, **768px**).
Quick checklist:
- No horizontal scroll
- Primary actions reachable without precision taps
- Focus rings visible when tabbing

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Payload for creating a board webhook.
*/
export interface BoardWebhookCreate {
/** @minLength 1 */
description: string;
enabled?: boolean;
}

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Response payload for inbound webhook ingestion.
*/
export interface BoardWebhookIngestResponse {
board_id: string;
ok?: boolean;
payload_id: string;
webhook_id: string;
}

View File

@@ -0,0 +1,22 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { BoardWebhookPayloadReadHeaders } from "./boardWebhookPayloadReadHeaders";
import type { BoardWebhookPayloadReadPayload } from "./boardWebhookPayloadReadPayload";
/**
* Serialized stored webhook payload.
*/
export interface BoardWebhookPayloadRead {
board_id: string;
content_type?: string | null;
headers?: BoardWebhookPayloadReadHeaders;
id: string;
payload?: BoardWebhookPayloadReadPayload;
received_at: string;
source_ip?: string | null;
webhook_id: string;
}

View File

@@ -0,0 +1,8 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type BoardWebhookPayloadReadHeaders = { [key: string]: string } | null;

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type BoardWebhookPayloadReadPayload =
| { [key: string]: unknown }
| unknown[]
| string
| number
| boolean
| null;

View File

@@ -0,0 +1,20 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Serialized board webhook configuration.
*/
export interface BoardWebhookRead {
board_id: string;
created_at: string;
description: string;
enabled: boolean;
endpoint_path: string;
endpoint_url?: string | null;
id: string;
updated_at: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Payload for updating a board webhook.
*/
export interface BoardWebhookUpdate {
description?: string | null;
enabled?: boolean | null;
}

View File

@@ -64,6 +64,13 @@ export * from "./boardReadSuccessMetrics";
export * from "./boardSnapshot";
export * from "./boardUpdate";
export * from "./boardUpdateSuccessMetrics";
export * from "./boardWebhookCreate";
export * from "./boardWebhookIngestResponse";
export * from "./boardWebhookPayloadRead";
export * from "./boardWebhookPayloadReadHeaders";
export * from "./boardWebhookPayloadReadPayload";
export * from "./boardWebhookRead";
export * from "./boardWebhookUpdate";
export * from "./dashboardKpis";
export * from "./dashboardMetrics";
export * from "./dashboardMetricsApiV1MetricsDashboardGetParams";
@@ -115,6 +122,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupMemoryRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardWebhookPayloadRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardWebhookRead";
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead";
@@ -133,6 +142,8 @@ export * from "./listBoardMemoryApiV1AgentBoardsBoardIdMemoryGetParams";
export * from "./listBoardMemoryApiV1BoardsBoardIdMemoryGetParams";
export * from "./listBoardsApiV1AgentBoardsGetParams";
export * from "./listBoardsApiV1BoardsGetParams";
export * from "./listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetParams";
export * from "./listBoardWebhooksApiV1BoardsBoardIdWebhooksGetParams";
export * from "./listGatewaysApiV1GatewaysGetParams";
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { BoardWebhookPayloadRead } from "./boardWebhookPayloadRead";
export interface LimitOffsetPageTypeVarCustomizedBoardWebhookPayloadRead {
items: BoardWebhookPayloadRead[];
/** @minimum 1 */
limit: number;
/** @minimum 0 */
offset: number;
/** @minimum 0 */
total: number;
}

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { BoardWebhookRead } from "./boardWebhookRead";
export interface LimitOffsetPageTypeVarCustomizedBoardWebhookRead {
items: BoardWebhookRead[];
/** @minimum 1 */
limit: number;
/** @minimum 0 */
offset: number;
/** @minimum 0 */
total: number;
}

View File

@@ -0,0 +1,19 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type ListBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetParams =
{
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
};

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type ListBoardWebhooksApiV1BoardsBoardIdWebhooksGetParams = {
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
};

View File

@@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { X } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
@@ -14,6 +15,14 @@ import {
useGetBoardApiV1BoardsBoardIdGet,
useUpdateBoardApiV1BoardsBoardIdPatch,
} from "@/api/generated/boards/boards";
import {
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey,
type listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
useCreateBoardWebhookApiV1BoardsBoardIdWebhooksPost,
useDeleteBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdDelete,
useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet,
useUpdateBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdPatch,
} from "@/api/generated/board-webhooks/board-webhooks";
import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
@@ -25,6 +34,7 @@ import {
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type {
BoardGroupRead,
BoardWebhookRead,
BoardRead,
BoardUpdate,
} from "@/api/generated/model";
@@ -51,8 +61,147 @@ const slugify = (value: string) =>
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board";
type WebhookCardProps = {
webhook: BoardWebhookRead;
isLoading: boolean;
isWebhookCreating: boolean;
isDeletingWebhook: boolean;
isUpdatingWebhook: boolean;
copiedWebhookId: string | null;
onCopy: (webhook: BoardWebhookRead) => void;
onDelete: (webhookId: string) => void;
onViewPayloads: (webhookId: string) => void;
onUpdate: (webhookId: string, description: string) => Promise<boolean>;
};
function WebhookCard({
webhook,
isLoading,
isWebhookCreating,
isDeletingWebhook,
isUpdatingWebhook,
copiedWebhookId,
onCopy,
onDelete,
onViewPayloads,
onUpdate,
}: WebhookCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [draftDescription, setDraftDescription] = useState(webhook.description);
const isBusy =
isLoading || isWebhookCreating || isDeletingWebhook || isUpdatingWebhook;
const trimmedDescription = draftDescription.trim();
const isDescriptionChanged =
trimmedDescription !== webhook.description.trim();
const handleSave = async () => {
if (!trimmedDescription) return;
if (!isDescriptionChanged) {
setIsEditing(false);
return;
}
const saved = await onUpdate(webhook.id, trimmedDescription);
if (saved) {
setIsEditing(false);
}
};
return (
<div
key={webhook.id}
className="space-y-3 rounded-lg border border-slate-200 px-4 py-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-slate-900">
Webhook {webhook.id.slice(0, 8)}
</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
onClick={() => onCopy(webhook)}
disabled={isBusy}
>
{copiedWebhookId === webhook.id ? "Copied" : "Copy endpoint"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => onViewPayloads(webhook.id)}
disabled={isBusy}
>
View payloads
</Button>
{isEditing ? (
<>
<Button
type="button"
variant="ghost"
onClick={() => {
setDraftDescription(webhook.description);
setIsEditing(false);
}}
disabled={isBusy}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isBusy || !trimmedDescription}
>
{isUpdatingWebhook ? "Saving…" : "Save"}
</Button>
</>
) : (
<>
<Button
type="button"
variant="ghost"
onClick={() => {
setDraftDescription(webhook.description);
setIsEditing(true);
}}
disabled={isBusy}
>
Edit
</Button>
<Button
type="button"
variant="ghost"
onClick={() => onDelete(webhook.id)}
disabled={isBusy}
>
{isDeletingWebhook ? "Deleting…" : "Delete"}
</Button>
</>
)}
</div>
</div>
{isEditing ? (
<Textarea
value={draftDescription}
onChange={(event) => setDraftDescription(event.target.value)}
placeholder="Describe exactly what the lead agent should do when payloads arrive."
className="min-h-[90px]"
disabled={isBusy}
/>
) : (
<p className="text-sm text-slate-700">{webhook.description}</p>
)}
<div className="rounded-md bg-slate-50 px-3 py-2">
<code className="break-all text-xs text-slate-700">
{webhook.endpoint_url ?? webhook.endpoint_path}
</code>
</div>
</div>
);
}
export default function EditBoardPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const router = useRouter();
const searchParams = useSearchParams();
const params = useParams();
@@ -89,6 +238,9 @@ export default function EditBoardPage() {
const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [webhookDescription, setWebhookDescription] = useState("");
const [webhookError, setWebhookError] = useState<string | null>(null);
const [copiedWebhookId, setCopiedWebhookId] = useState<string | null>(null);
const onboardingParam = searchParams.get("onboarding");
const searchParamsString = searchParams.toString();
@@ -170,6 +322,20 @@ export default function EditBoardPage() {
retry: false,
},
});
const webhooksQuery = useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet<
listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
ApiError
>(
boardId ?? "",
{ limit: 50 },
{
query: {
enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always",
retry: false,
},
},
);
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
mutation: {
@@ -183,6 +349,58 @@ export default function EditBoardPage() {
},
},
});
const createWebhookMutation =
useCreateBoardWebhookApiV1BoardsBoardIdWebhooksPost<ApiError>({
mutation: {
onSuccess: async () => {
if (!boardId) return;
setWebhookDescription("");
await queryClient.invalidateQueries({
queryKey:
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
boardId,
),
});
},
onError: (err) => {
setWebhookError(err.message || "Unable to create webhook.");
},
},
});
const deleteWebhookMutation =
useDeleteBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdDelete<ApiError>({
mutation: {
onSuccess: async () => {
if (!boardId) return;
await queryClient.invalidateQueries({
queryKey:
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
boardId,
),
});
},
onError: (err) => {
setWebhookError(err.message || "Unable to delete webhook.");
},
},
});
const updateWebhookMutation =
useUpdateBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdPatch<ApiError>({
mutation: {
onSuccess: async () => {
if (!boardId) return;
await queryClient.invalidateQueries({
queryKey:
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey(
boardId,
),
});
},
onError: (err) => {
setWebhookError(err.message || "Unable to update webhook.");
},
},
});
const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return [];
@@ -216,6 +434,19 @@ export default function EditBoardPage() {
targetDate ?? toLocalDateInput(baseBoard?.target_date);
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || "";
const isWebhookCreating = createWebhookMutation.isPending;
const deletingWebhookId =
deleteWebhookMutation.isPending && deleteWebhookMutation.variables
? deleteWebhookMutation.variables.webhookId
: null;
const updatingWebhookId =
updateWebhookMutation.isPending && updateWebhookMutation.variables
? updateWebhookMutation.variables.webhookId
: null;
const isWebhookBusy =
isWebhookCreating ||
deleteWebhookMutation.isPending ||
updateWebhookMutation.isPending;
const isLoading =
gatewaysQuery.isLoading ||
@@ -228,6 +459,8 @@ export default function EditBoardPage() {
groupsQuery.error?.message ??
boardQuery.error?.message ??
null;
const webhookErrorMessage =
webhookError ?? webhooksQuery.error?.message ?? null;
const isFormReady = Boolean(
resolvedName.trim() && resolvedDescription.trim() && displayGatewayId,
@@ -250,6 +483,10 @@ export default function EditBoardPage() {
],
[groups],
);
const webhooks = useMemo<BoardWebhookRead[]>(() => {
if (webhooksQuery.data?.status !== 200) return [];
return webhooksQuery.data.data.items ?? [];
}, [webhooksQuery.data]);
const handleOnboardingConfirmed = (updated: BoardRead) => {
setBoard(updated);
@@ -294,10 +531,7 @@ export default function EditBoardPage() {
setMetricsError(null);
let parsedMetrics: Record<string, unknown> | null = null;
if (
resolvedBoardType !== "general" &&
resolvedSuccessMetrics.trim()
) {
if (resolvedBoardType !== "general" && resolvedSuccessMetrics.trim()) {
try {
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
string,
@@ -335,6 +569,74 @@ export default function EditBoardPage() {
updateBoardMutation.mutate({ boardId, data: payload });
};
const handleCreateWebhook = () => {
if (!boardId) return;
const trimmedDescription = webhookDescription.trim();
if (!trimmedDescription) {
setWebhookError("Webhook instruction is required.");
return;
}
setWebhookError(null);
createWebhookMutation.mutate({
boardId,
data: {
description: trimmedDescription,
enabled: true,
},
});
};
const handleDeleteWebhook = (webhookId: string) => {
if (!boardId) return;
if (deleteWebhookMutation.isPending) return;
setWebhookError(null);
deleteWebhookMutation.mutate({ boardId, webhookId });
};
const handleUpdateWebhook = async (
webhookId: string,
description: string,
): Promise<boolean> => {
if (!boardId) return false;
if (updateWebhookMutation.isPending) return false;
const trimmedDescription = description.trim();
if (!trimmedDescription) {
setWebhookError("Webhook instruction is required.");
return false;
}
setWebhookError(null);
try {
await updateWebhookMutation.mutateAsync({
boardId,
webhookId,
data: { description: trimmedDescription },
});
return true;
} catch {
return false;
}
};
const handleCopyWebhookEndpoint = async (webhook: BoardWebhookRead) => {
const endpoint = (webhook.endpoint_url ?? webhook.endpoint_path).trim();
try {
await navigator.clipboard.writeText(endpoint);
setCopiedWebhookId(webhook.id);
window.setTimeout(() => {
setCopiedWebhookId((current) =>
current === webhook.id ? null : current,
);
}, 1500);
} catch {
setWebhookError("Unable to copy webhook endpoint.");
}
};
const handleViewWebhookPayloads = (webhookId: string) => {
if (!boardId) return;
router.push(`/boards/${boardId}/webhooks/${webhookId}/payloads`);
};
return (
<>
<DashboardPageLayout
@@ -510,7 +812,9 @@ export default function EditBoardPage() {
<section className="space-y-3 border-t border-slate-200 pt-4">
<div>
<h2 className="text-base font-semibold text-slate-900">Rules</h2>
<h2 className="text-base font-semibold text-slate-900">
Rules
</h2>
<p className="text-xs text-slate-600">
Configure board-level workflow enforcement.
</p>
@@ -650,6 +954,84 @@ export default function EditBoardPage() {
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
<section className="space-y-4 border-t border-slate-200 pt-4">
<div>
<h2 className="text-base font-semibold text-slate-900">
Webhooks
</h2>
<p className="text-xs text-slate-600">
Add inbound webhook endpoints so the lead agent can react to
external events.
</p>
</div>
<div className="space-y-3 rounded-lg border border-slate-200 px-4 py-4">
<label className="text-sm font-medium text-slate-900">
Lead agent instruction
</label>
<Textarea
value={webhookDescription}
onChange={(event) =>
setWebhookDescription(event.target.value)
}
placeholder="Describe exactly what the lead agent should do when payloads arrive."
className="min-h-[90px]"
disabled={isLoading || isWebhookBusy}
/>
<div className="flex justify-end">
<Button
type="button"
onClick={handleCreateWebhook}
disabled={
isLoading ||
isWebhookBusy ||
!baseBoard ||
!webhookDescription.trim()
}
>
{createWebhookMutation.isPending
? "Creating webhook…"
: "Create webhook"}
</Button>
</div>
</div>
{webhookErrorMessage ? (
<p className="text-sm text-red-500">{webhookErrorMessage}</p>
) : null}
{webhooksQuery.isLoading ? (
<p className="text-sm text-slate-500">Loading webhooks</p>
) : null}
{!webhooksQuery.isLoading && webhooks.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-3 text-sm text-slate-600">
No webhooks configured yet.
</p>
) : null}
<div className="space-y-3">
{webhooks.map((webhook) => {
const isDeletingWebhook = deletingWebhookId === webhook.id;
const isUpdatingWebhook = updatingWebhookId === webhook.id;
return (
<WebhookCard
key={webhook.id}
webhook={webhook}
isLoading={isLoading}
isWebhookCreating={isWebhookCreating}
isDeletingWebhook={isDeletingWebhook}
isUpdatingWebhook={isUpdatingWebhook}
copiedWebhookId={copiedWebhookId}
onCopy={handleCopyWebhookEndpoint}
onDelete={handleDeleteWebhook}
onViewPayloads={handleViewWebhookPayloads}
onUpdate={handleUpdateWebhook}
/>
);
})}
</div>
</section>
</form>
</div>
</DashboardPageLayout>

View File

@@ -0,0 +1,216 @@
"use client";
export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type getBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGetResponse,
type listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetResponse,
useGetBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGet,
useListBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGet,
} from "@/api/generated/board-webhooks/board-webhooks";
import type { BoardWebhookPayloadRead } from "@/api/generated/model";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
const PAGE_LIMIT = 20;
const stringifyPayload = (value: unknown) => {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
export default function WebhookPayloadsPage() {
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const router = useRouter();
const params = useParams();
const boardIdParam = params?.boardId;
const webhookIdParam = params?.webhookId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const webhookId = Array.isArray(webhookIdParam)
? webhookIdParam[0]
: webhookIdParam;
const [offset, setOffset] = useState(0);
const webhookQuery = useGetBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGet<
getBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdGetResponse,
ApiError
>(boardId ?? "", webhookId ?? "", {
query: {
enabled: Boolean(isSignedIn && isAdmin && boardId && webhookId),
refetchOnMount: "always",
retry: false,
},
});
const payloadsQuery =
useListBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGet<
listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetResponse,
ApiError
>(
boardId ?? "",
webhookId ?? "",
{ limit: PAGE_LIMIT, offset },
{
query: {
enabled: Boolean(isSignedIn && isAdmin && boardId && webhookId),
refetchOnMount: "always",
retry: false,
},
},
);
const webhook =
webhookQuery.data?.status === 200 ? webhookQuery.data.data : null;
const payloadPage =
payloadsQuery.data?.status === 200 ? payloadsQuery.data.data : null;
const payloads = payloadPage?.items ?? [];
const total = payloadPage?.total ?? 0;
const currentPage = Math.floor(offset / PAGE_LIMIT) + 1;
const pageCount = Math.max(1, Math.ceil(total / PAGE_LIMIT));
const hasPrev = offset > 0;
const hasNext = offset + PAGE_LIMIT < total;
const errorMessage =
payloadsQuery.error?.message ?? webhookQuery.error?.message ?? null;
const isLoading = payloadsQuery.isLoading || webhookQuery.isLoading;
const payloadTitle = useMemo(() => {
if (!webhook) return "Webhook payloads";
return `Webhook ${webhook.id.slice(0, 8)} payloads`;
}, [webhook]);
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to view webhook payloads.",
forceRedirectUrl: `/boards/${boardId}/webhooks/${webhookId}/payloads`,
signUpForceRedirectUrl: `/boards/${boardId}/webhooks/${webhookId}/payloads`,
}}
title="Webhook payloads"
description="Review payloads received by this webhook."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can view webhook payloads."
>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-base font-semibold text-slate-900">
{payloadTitle}
</h2>
<p className="text-sm text-slate-600">
{webhook?.description ?? "Loading webhook details..."}
</p>
</div>
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}/edit`)}
>
Back to board settings
</Button>
</div>
{webhook ? (
<div className="rounded-md bg-slate-50 px-3 py-2">
<code className="break-all text-xs text-slate-700">
{webhook.endpoint_url ?? webhook.endpoint_path}
</code>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2">
<p className="text-sm text-slate-700">
{total} payload{total === 1 ? "" : "s"} total
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() =>
setOffset((current) => Math.max(0, current - PAGE_LIMIT))
}
disabled={!hasPrev || isLoading}
>
Previous
</Button>
<span className="text-xs text-slate-600">
Page {currentPage} of {pageCount}
</span>
<Button
type="button"
variant="ghost"
onClick={() => setOffset((current) => current + PAGE_LIMIT)}
disabled={!hasNext || isLoading}
>
Next
</Button>
</div>
</div>
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
{isLoading ? (
<p className="text-sm text-slate-500">Loading payloads...</p>
) : null}
{!isLoading && payloads.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-3 text-sm text-slate-600">
No payloads received for this webhook yet.
</p>
) : null}
<div className="space-y-3">
{payloads.map((payload: BoardWebhookPayloadRead) => (
<div
key={payload.id}
className="space-y-3 rounded-lg border border-slate-200 px-4 py-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-slate-900">
Payload {payload.id.slice(0, 8)}
</span>
<span className="text-xs text-slate-500">
{new Date(payload.received_at).toLocaleString()}
</span>
</div>
<div className="grid gap-2 text-xs text-slate-600 md:grid-cols-2">
<p>
Content type:{" "}
<code>{payload.content_type ?? "not provided"}</code>
</p>
<p>
Source IP: <code>{payload.source_ip ?? "not provided"}</code>
</p>
</div>
<pre className="max-h-96 overflow-auto rounded-md bg-slate-900/95 p-3 text-xs text-slate-100">
{stringifyPayload(payload.payload)}
</pre>
</div>
))}
</div>
</div>
</DashboardPageLayout>
);
}

View File

@@ -342,155 +342,155 @@ export const TaskBoard = memo(function TaskBoard({
"sm:grid-flow-col sm:auto-cols-[minmax(260px,320px)] sm:grid-cols-none sm:overflow-x-auto",
)}
>
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
const reviewCounts =
column.status === "review"
? columnTasks.reduce(
(acc, task) => {
if (task.is_blocked) {
acc.blocked += 1;
return acc;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
const reviewCounts =
column.status === "review"
? columnTasks.reduce(
(acc, task) => {
if (task.is_blocked) {
acc.blocked += 1;
return acc;
},
{
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
return acc;
},
{
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
const filteredTasks =
column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => {
if (reviewBucket === "blocked")
return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed")
return (
(task.approvals_pending_count ?? 0) > 0 &&
!task.is_blocked
);
if (reviewBucket === "waiting_lead")
return (
!task.is_blocked &&
(task.approvals_pending_count ?? 0) === 0
);
return true;
})
: columnTasks;
const filteredTasks =
column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => {
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed")
return (
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
);
if (reviewBucket === "waiting_lead")
return (
!task.is_blocked &&
(task.approvals_pending_count ?? 0) === 0
);
return true;
})
: columnTasks;
return (
<div
key={column.title}
className={cn(
// On mobile, columns are stacked, so avoid forcing tall fixed heights.
"kanban-column min-h-0",
// On larger screens, keep columns tall to reduce empty space during drag.
"sm:min-h-[calc(100vh-260px)]",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
>
<div className="column-header z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white px-4 py-3 sm:sticky sm:top-0 sm:bg-white/80 sm:backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{filteredTasks.length}
</span>
return (
<div
key={column.title}
className={cn(
// On mobile, columns are stacked, so avoid forcing tall fixed heights.
"kanban-column min-h-0",
// On larger screens, keep columns tall to reduce empty space during drag.
"sm:min-h-[calc(100vh-260px)]",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
>
<div className="column-header z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white px-4 py-3 sm:sticky sm:top-0 sm:bg-white/80 sm:backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
{column.status === "review" && reviewCounts ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{(
[
{ key: "all", label: "All", count: reviewCounts.all },
{
key: "approval_needed",
label: "Approval needed",
count: reviewCounts.approval_needed,
},
{
key: "waiting_lead",
label: "Lead review",
count: reviewCounts.waiting_lead,
},
{
key: "blocked",
label: "Blocked",
count: reviewCounts.blocked,
},
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => setReviewBucket(option.key)}
className={cn(
"rounded-full border px-2.5 py-1 transition",
reviewBucket === option.key
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
aria-pressed={reviewBucket === option.key}
>
{option.label} · {option.count}
</button>
))}
</div>
) : null}
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{filteredTasks.length}
</span>
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{filteredTasks.map((task) => {
const dueState = resolveDueState(task);
return (
<div key={task.id} ref={setCardRef(task.id)}>
<TaskCard
title={task.title}
status={task.status}
priority={task.priority}
assignee={task.assignee ?? undefined}
due={dueState.due}
isOverdue={dueState.isOverdue}
approvalsPendingCount={task.approvals_pending_count}
tags={task.tags}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={readOnly ? undefined : handleDragStart(task)}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
</div>
);
})}
{column.status === "review" && reviewCounts ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{(
[
{ key: "all", label: "All", count: reviewCounts.all },
{
key: "approval_needed",
label: "Approval needed",
count: reviewCounts.approval_needed,
},
{
key: "waiting_lead",
label: "Lead review",
count: reviewCounts.waiting_lead,
},
{
key: "blocked",
label: "Blocked",
count: reviewCounts.blocked,
},
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => setReviewBucket(option.key)}
className={cn(
"rounded-full border px-2.5 py-1 transition",
reviewBucket === option.key
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
aria-pressed={reviewBucket === option.key}
>
{option.label} · {option.count}
</button>
))}
</div>
) : null}
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{filteredTasks.map((task) => {
const dueState = resolveDueState(task);
return (
<div key={task.id} ref={setCardRef(task.id)}>
<TaskCard
title={task.title}
status={task.status}
priority={task.priority}
assignee={task.assignee ?? undefined}
due={dueState.due}
isOverdue={dueState.isOverdue}
approvalsPendingCount={task.approvals_pending_count}
tags={task.tags}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={
readOnly ? undefined : handleDragStart(task)
}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</div>
);
});