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

@@ -0,0 +1,451 @@
"""Board webhook configuration and inbound payload ingestion endpoints."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlmodel import col, select
from app.api.deps import get_board_for_user_read, get_board_for_user_write, get_board_or_404
from app.core.config import settings
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.models.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.schemas.board_webhooks import (
BoardWebhookCreate,
BoardWebhookIngestResponse,
BoardWebhookPayloadRead,
BoardWebhookRead,
BoardWebhookUpdate,
)
from app.schemas.common import OkResponse
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
if TYPE_CHECKING:
from collections.abc import Sequence
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.boards import Board
router = APIRouter(prefix="/boards/{board_id}/webhooks", tags=["board-webhooks"])
SESSION_DEP = Depends(get_session)
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
BOARD_OR_404_DEP = Depends(get_board_or_404)
PAYLOAD_PREVIEW_MAX_CHARS = 1600
def _webhook_endpoint_path(board_id: UUID, webhook_id: UUID) -> str:
return f"/api/v1/boards/{board_id}/webhooks/{webhook_id}"
def _webhook_endpoint_url(endpoint_path: str) -> str | None:
base_url = settings.base_url.rstrip("/")
if not base_url:
return None
return f"{base_url}{endpoint_path}"
def _to_webhook_read(webhook: BoardWebhook) -> BoardWebhookRead:
endpoint_path = _webhook_endpoint_path(webhook.board_id, webhook.id)
return BoardWebhookRead(
id=webhook.id,
board_id=webhook.board_id,
description=webhook.description,
enabled=webhook.enabled,
endpoint_path=endpoint_path,
endpoint_url=_webhook_endpoint_url(endpoint_path),
created_at=webhook.created_at,
updated_at=webhook.updated_at,
)
def _to_payload_read(payload: BoardWebhookPayload) -> BoardWebhookPayloadRead:
return BoardWebhookPayloadRead.model_validate(payload, from_attributes=True)
def _coerce_webhook_items(items: Sequence[object]) -> list[BoardWebhook]:
values: list[BoardWebhook] = []
for item in items:
if not isinstance(item, BoardWebhook):
msg = "Expected BoardWebhook items from paginated query"
raise TypeError(msg)
values.append(item)
return values
def _coerce_payload_items(items: Sequence[object]) -> list[BoardWebhookPayload]:
values: list[BoardWebhookPayload] = []
for item in items:
if not isinstance(item, BoardWebhookPayload):
msg = "Expected BoardWebhookPayload items from paginated query"
raise TypeError(msg)
values.append(item)
return values
async def _require_board_webhook(
session: AsyncSession,
*,
board_id: UUID,
webhook_id: UUID,
) -> BoardWebhook:
webhook = (
await session.exec(
select(BoardWebhook)
.where(col(BoardWebhook.id) == webhook_id)
.where(col(BoardWebhook.board_id) == board_id),
)
).first()
if webhook is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return webhook
async def _require_board_webhook_payload(
session: AsyncSession,
*,
board_id: UUID,
webhook_id: UUID,
payload_id: UUID,
) -> BoardWebhookPayload:
payload = (
await session.exec(
select(BoardWebhookPayload)
.where(col(BoardWebhookPayload.id) == payload_id)
.where(col(BoardWebhookPayload.board_id) == board_id)
.where(col(BoardWebhookPayload.webhook_id) == webhook_id),
)
).first()
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return payload
def _decode_payload(
raw_body: bytes,
*,
content_type: str | None,
) -> dict[str, object] | list[object] | str | int | float | bool | None:
if not raw_body:
return {}
body_text = raw_body.decode("utf-8", errors="replace")
normalized_content_type = (content_type or "").lower()
should_parse_json = "application/json" in normalized_content_type
if not should_parse_json:
should_parse_json = body_text.startswith(("{", "[", '"')) or body_text in {"true", "false"}
if should_parse_json:
try:
parsed = json.loads(body_text)
except json.JSONDecodeError:
return body_text
if isinstance(parsed, (dict, list, str, int, float, bool)) or parsed is None:
return parsed
return body_text
def _captured_headers(request: Request) -> dict[str, str] | None:
captured: dict[str, str] = {}
for header, value in request.headers.items():
normalized = header.lower()
if normalized in {"content-type", "user-agent"} or normalized.startswith("x-"):
captured[normalized] = value
return captured or None
def _payload_preview(
value: dict[str, object] | list[object] | str | int | float | bool | None,
) -> str:
if isinstance(value, str):
preview = value
else:
try:
preview = json.dumps(value, indent=2, ensure_ascii=True)
except TypeError:
preview = str(value)
if len(preview) <= PAYLOAD_PREVIEW_MAX_CHARS:
return preview
return f"{preview[: PAYLOAD_PREVIEW_MAX_CHARS - 3]}..."
def _webhook_memory_content(
*,
webhook: BoardWebhook,
payload: BoardWebhookPayload,
) -> str:
preview = _payload_preview(payload.payload)
inspect_path = f"/api/v1/boards/{webhook.board_id}/webhooks/{webhook.id}/payloads/{payload.id}"
return (
"WEBHOOK PAYLOAD RECEIVED\n"
f"Webhook ID: {webhook.id}\n"
f"Payload ID: {payload.id}\n"
f"Instruction: {webhook.description}\n"
f"Inspect (admin API): {inspect_path}\n\n"
"Payload preview:\n"
f"{preview}"
)
async def _notify_lead_on_webhook_payload(
*,
session: AsyncSession,
board: Board,
webhook: BoardWebhook,
payload: BoardWebhookPayload,
) -> None:
lead = (
await Agent.objects.filter_by(board_id=board.id)
.filter(col(Agent.is_board_lead).is_(True))
.first(session)
)
if lead is None or not lead.openclaw_session_id:
return
dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if config is None:
return
payload_preview = _payload_preview(payload.payload)
message = (
"WEBHOOK EVENT RECEIVED\n"
f"Board: {board.name}\n"
f"Webhook ID: {webhook.id}\n"
f"Payload ID: {payload.id}\n"
f"Instruction: {webhook.description}\n\n"
"Take action:\n"
"1) Triage this payload against the webhook instruction.\n"
"2) Create/update tasks as needed.\n"
f"3) Reference payload ID {payload.id} in task descriptions.\n\n"
"Payload preview:\n"
f"{payload_preview}\n\n"
"To inspect board memory entries:\n"
f"GET /api/v1/agent/boards/{board.id}/memory?is_chat=false"
)
await dispatch.try_send_agent_message(
session_key=lead.openclaw_session_id,
config=config,
agent_name=lead.name,
message=message,
deliver=False,
)
@router.get("", response_model=DefaultLimitOffsetPage[BoardWebhookRead])
async def list_board_webhooks(
board: Board = BOARD_USER_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> LimitOffsetPage[BoardWebhookRead]:
"""List configured webhooks for a board."""
statement = (
select(BoardWebhook)
.where(col(BoardWebhook.board_id) == board.id)
.order_by(col(BoardWebhook.created_at).desc())
)
def _transform(items: Sequence[object]) -> Sequence[object]:
webhooks = _coerce_webhook_items(items)
return [_to_webhook_read(value) for value in webhooks]
return await paginate(session, statement, transformer=_transform)
@router.post("", response_model=BoardWebhookRead)
async def create_board_webhook(
payload: BoardWebhookCreate,
board: Board = BOARD_USER_WRITE_DEP,
session: AsyncSession = SESSION_DEP,
) -> BoardWebhookRead:
"""Create a new board webhook with a generated UUID endpoint."""
webhook = BoardWebhook(
board_id=board.id,
description=payload.description,
enabled=payload.enabled,
)
await crud.save(session, webhook)
return _to_webhook_read(webhook)
@router.get("/{webhook_id}", response_model=BoardWebhookRead)
async def get_board_webhook(
webhook_id: UUID,
board: Board = BOARD_USER_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> BoardWebhookRead:
"""Get one board webhook configuration."""
webhook = await _require_board_webhook(
session,
board_id=board.id,
webhook_id=webhook_id,
)
return _to_webhook_read(webhook)
@router.patch("/{webhook_id}", response_model=BoardWebhookRead)
async def update_board_webhook(
webhook_id: UUID,
payload: BoardWebhookUpdate,
board: Board = BOARD_USER_WRITE_DEP,
session: AsyncSession = SESSION_DEP,
) -> BoardWebhookRead:
"""Update board webhook description or enabled state."""
webhook = await _require_board_webhook(
session,
board_id=board.id,
webhook_id=webhook_id,
)
updates = payload.model_dump(exclude_unset=True)
if updates:
crud.apply_updates(webhook, updates)
webhook.updated_at = utcnow()
await crud.save(session, webhook)
return _to_webhook_read(webhook)
@router.delete("/{webhook_id}", response_model=OkResponse)
async def delete_board_webhook(
webhook_id: UUID,
board: Board = BOARD_USER_WRITE_DEP,
session: AsyncSession = SESSION_DEP,
) -> OkResponse:
"""Delete a webhook and its stored payload rows."""
webhook = await _require_board_webhook(
session,
board_id=board.id,
webhook_id=webhook_id,
)
await crud.delete_where(
session,
BoardWebhookPayload,
col(BoardWebhookPayload.webhook_id) == webhook.id,
commit=False,
)
await session.delete(webhook)
await session.commit()
return OkResponse()
@router.get(
"/{webhook_id}/payloads", response_model=DefaultLimitOffsetPage[BoardWebhookPayloadRead]
)
async def list_board_webhook_payloads(
webhook_id: UUID,
board: Board = BOARD_USER_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> LimitOffsetPage[BoardWebhookPayloadRead]:
"""List stored payloads for one board webhook."""
await _require_board_webhook(
session,
board_id=board.id,
webhook_id=webhook_id,
)
statement = (
select(BoardWebhookPayload)
.where(col(BoardWebhookPayload.board_id) == board.id)
.where(col(BoardWebhookPayload.webhook_id) == webhook_id)
.order_by(col(BoardWebhookPayload.received_at).desc())
)
def _transform(items: Sequence[object]) -> Sequence[object]:
payloads = _coerce_payload_items(items)
return [_to_payload_read(value) for value in payloads]
return await paginate(session, statement, transformer=_transform)
@router.get("/{webhook_id}/payloads/{payload_id}", response_model=BoardWebhookPayloadRead)
async def get_board_webhook_payload(
webhook_id: UUID,
payload_id: UUID,
board: Board = BOARD_USER_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> BoardWebhookPayloadRead:
"""Get a single stored payload for one board webhook."""
await _require_board_webhook(
session,
board_id=board.id,
webhook_id=webhook_id,
)
payload = await _require_board_webhook_payload(
session,
board_id=board.id,
webhook_id=webhook_id,
payload_id=payload_id,
)
return _to_payload_read(payload)
@router.post(
"/{webhook_id}",
response_model=BoardWebhookIngestResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def ingest_board_webhook(
request: Request,
webhook_id: UUID,
board: Board = BOARD_OR_404_DEP,
session: AsyncSession = SESSION_DEP,
) -> BoardWebhookIngestResponse:
"""Open inbound webhook endpoint that stores payloads and nudges the board lead."""
webhook = await _require_board_webhook(
session,
board_id=board.id,
webhook_id=webhook_id,
)
if not webhook.enabled:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Webhook is disabled.",
)
content_type = request.headers.get("content-type")
payload_value = _decode_payload(
await request.body(),
content_type=content_type,
)
payload = BoardWebhookPayload(
board_id=board.id,
webhook_id=webhook.id,
payload=payload_value,
headers=_captured_headers(request),
source_ip=request.client.host if request.client else None,
content_type=content_type,
)
session.add(payload)
memory = BoardMemory(
board_id=board.id,
content=_webhook_memory_content(webhook=webhook, payload=payload),
tags=[
"webhook",
f"webhook:{webhook.id}",
f"payload:{payload.id}",
],
source="webhook",
is_chat=False,
)
session.add(memory)
await session.commit()
await _notify_lead_on_webhook_payload(
session=session,
board=board,
webhook=webhook,
payload=payload,
)
return BoardWebhookIngestResponse(
board_id=board.id,
webhook_id=webhook.id,
payload_id=payload.id,
)

View File

@@ -24,6 +24,8 @@ from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup from app.models.board_groups import BoardGroup
from app.models.board_memory import BoardMemory from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession from app.models.board_onboarding import BoardOnboardingSession
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_board_access import OrganizationBoardAccess
@@ -290,6 +292,18 @@ async def delete_my_org(
col(BoardMemory.board_id).in_(board_ids), col(BoardMemory.board_id).in_(board_ids),
commit=False, commit=False,
) )
await crud.delete_where(
session,
BoardWebhookPayload,
col(BoardWebhookPayload.board_id).in_(board_ids),
commit=False,
)
await crud.delete_where(
session,
BoardWebhook,
col(BoardWebhook.board_id).in_(board_ids),
commit=False,
)
await crud.delete_where( await crud.delete_where(
session, session,
BoardOnboardingSession, BoardOnboardingSession,

View File

@@ -120,9 +120,7 @@ def _approval_required_for_done_error() -> HTTPException:
return HTTPException( return HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail={ detail={
"message": ( "message": ("Task can only be marked done when a linked approval has been approved."),
"Task can only be marked done when a linked approval has been approved."
),
"blocked_by_task_ids": [], "blocked_by_task_ids": [],
}, },
) )
@@ -132,9 +130,7 @@ def _review_required_for_done_error() -> HTTPException:
return HTTPException( return HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail={ detail={
"message": ( "message": ("Task can only be marked done from review when the board rule is enabled."),
"Task can only be marked done from review when the board rule is enabled."
),
"blocked_by_task_ids": [], "blocked_by_task_ids": [],
}, },
) )
@@ -144,9 +140,7 @@ def _pending_approval_blocks_status_change_error() -> HTTPException:
return HTTPException( return HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail={ detail={
"message": ( "message": ("Task status cannot be changed while a linked approval is pending."),
"Task status cannot be changed while a linked approval is pending."
),
"blocked_by_task_ids": [], "blocked_by_task_ids": [],
}, },
) )

View File

@@ -18,6 +18,7 @@ from app.api.board_group_memory import router as board_group_memory_router
from app.api.board_groups import router as board_groups_router from app.api.board_groups import router as board_groups_router
from app.api.board_memory import router as board_memory_router from app.api.board_memory import router as board_memory_router
from app.api.board_onboarding import router as board_onboarding_router from app.api.board_onboarding import router as board_onboarding_router
from app.api.board_webhooks import router as board_webhooks_router
from app.api.boards import router as boards_router from app.api.boards import router as boards_router
from app.api.gateway import router as gateway_router from app.api.gateway import router as gateway_router
from app.api.gateways import router as gateways_router from app.api.gateways import router as gateways_router
@@ -105,6 +106,7 @@ api_v1.include_router(board_groups_router)
api_v1.include_router(board_group_memory_router) api_v1.include_router(board_group_memory_router)
api_v1.include_router(boards_router) api_v1.include_router(boards_router)
api_v1.include_router(board_memory_router) api_v1.include_router(board_memory_router)
api_v1.include_router(board_webhooks_router)
api_v1.include_router(board_onboarding_router) api_v1.include_router(board_onboarding_router)
api_v1.include_router(approvals_router) api_v1.include_router(approvals_router)
api_v1.include_router(tasks_router) api_v1.include_router(tasks_router)

View File

@@ -8,6 +8,8 @@ from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup from app.models.board_groups import BoardGroup
from app.models.board_memory import BoardMemory from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession from app.models.board_onboarding import BoardOnboardingSession
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_board_access import OrganizationBoardAccess
@@ -28,6 +30,8 @@ __all__ = [
"ApprovalTaskLink", "ApprovalTaskLink",
"Approval", "Approval",
"BoardGroupMemory", "BoardGroupMemory",
"BoardWebhook",
"BoardWebhookPayload",
"BoardMemory", "BoardMemory",
"BoardOnboardingSession", "BoardOnboardingSession",
"BoardGroup", "BoardGroup",

View File

@@ -0,0 +1,32 @@
"""Persisted webhook payloads received for board webhooks."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field
from app.core.time import utcnow
from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class BoardWebhookPayload(QueryModel, table=True):
"""Captured inbound webhook payload with request metadata."""
__tablename__ = "board_webhook_payloads" # pyright: ignore[reportAssignmentType]
id: UUID = Field(default_factory=uuid4, primary_key=True)
board_id: UUID = Field(foreign_key="boards.id", index=True)
webhook_id: UUID = Field(foreign_key="board_webhooks.id", index=True)
payload: dict[str, object] | list[object] | str | int | float | bool | None = Field(
default=None,
sa_column=Column(JSON),
)
headers: dict[str, str] | None = Field(default=None, sa_column=Column(JSON))
source_ip: str | None = None
content_type: str | None = None
received_at: datetime = Field(default_factory=utcnow, index=True)

View File

@@ -0,0 +1,26 @@
"""Board webhook configuration model."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlmodel import Field
from app.core.time import utcnow
from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class BoardWebhook(QueryModel, table=True):
"""Inbound webhook endpoint configuration for a board."""
__tablename__ = "board_webhooks" # pyright: ignore[reportAssignmentType]
id: UUID = Field(default_factory=uuid4, primary_key=True)
board_id: UUID = Field(foreign_key="boards.id", index=True)
description: str
enabled: bool = Field(default=True, index=True)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -11,6 +11,13 @@ from app.schemas.board_onboarding import (
BoardOnboardingRead, BoardOnboardingRead,
BoardOnboardingStart, BoardOnboardingStart,
) )
from app.schemas.board_webhooks import (
BoardWebhookCreate,
BoardWebhookIngestResponse,
BoardWebhookPayloadRead,
BoardWebhookRead,
BoardWebhookUpdate,
)
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
from app.schemas.metrics import DashboardMetrics from app.schemas.metrics import DashboardMetrics
@@ -47,6 +54,11 @@ __all__ = [
"BoardGroupMemoryRead", "BoardGroupMemoryRead",
"BoardMemoryCreate", "BoardMemoryCreate",
"BoardMemoryRead", "BoardMemoryRead",
"BoardWebhookCreate",
"BoardWebhookIngestResponse",
"BoardWebhookPayloadRead",
"BoardWebhookRead",
"BoardWebhookUpdate",
"BoardOnboardingAnswer", "BoardOnboardingAnswer",
"BoardOnboardingConfirm", "BoardOnboardingConfirm",
"BoardOnboardingRead", "BoardOnboardingRead",

View File

@@ -0,0 +1,61 @@
"""Schemas for board webhook configuration and payload capture endpoints."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
class BoardWebhookCreate(SQLModel):
"""Payload for creating a board webhook."""
description: NonEmptyStr
enabled: bool = True
class BoardWebhookUpdate(SQLModel):
"""Payload for updating a board webhook."""
description: NonEmptyStr | None = None
enabled: bool | None = None
class BoardWebhookRead(SQLModel):
"""Serialized board webhook configuration."""
id: UUID
board_id: UUID
description: str
enabled: bool
endpoint_path: str
endpoint_url: str | None = None
created_at: datetime
updated_at: datetime
class BoardWebhookPayloadRead(SQLModel):
"""Serialized stored webhook payload."""
id: UUID
board_id: UUID
webhook_id: UUID
payload: dict[str, object] | list[object] | str | int | float | bool | None = None
headers: dict[str, str] | None = None
source_ip: str | None = None
content_type: str | None = None
received_at: datetime
class BoardWebhookIngestResponse(SQLModel):
"""Response payload for inbound webhook ingestion."""
ok: bool = True
board_id: UUID
webhook_id: UUID
payload_id: UUID

View File

@@ -18,6 +18,8 @@ from app.models.approval_task_links import ApprovalTaskLink
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.board_memory import BoardMemory from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession from app.models.board_onboarding import BoardOnboardingSession
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.task_dependencies import TaskDependency from app.models.task_dependencies import TaskDependency
@@ -84,6 +86,12 @@ async def delete_board(session: AsyncSession, *, board: Board) -> OkResponse:
await crud.delete_where(session, Approval, col(Approval.board_id) == board.id) await crud.delete_where(session, Approval, col(Approval.board_id) == board.id)
await crud.delete_where(session, BoardMemory, col(BoardMemory.board_id) == board.id) await crud.delete_where(session, BoardMemory, col(BoardMemory.board_id) == board.id)
await crud.delete_where(
session,
BoardWebhookPayload,
col(BoardWebhookPayload.board_id) == board.id,
)
await crud.delete_where(session, BoardWebhook, col(BoardWebhook.board_id) == board.id)
await crud.delete_where( await crud.delete_where(
session, session,
BoardOnboardingSession, BoardOnboardingSession,

View File

@@ -0,0 +1,130 @@
"""Add board webhook configuration and payload storage tables.
Revision ID: fa6e83f8d9a1
Revises: c2e9f1a6d4b8
Create Date: 2026-02-13 00:10:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "fa6e83f8d9a1"
down_revision = "c2e9f1a6d4b8"
branch_labels = None
depends_on = None
def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]:
return {item["name"] for item in inspector.get_indexes(table_name)}
def upgrade() -> None:
"""Create board webhook and payload capture tables."""
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("board_webhooks"):
op.create_table(
"board_webhooks",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("description", sa.String(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.PrimaryKeyConstraint("id"),
)
inspector = sa.inspect(bind)
webhook_indexes = _index_names(inspector, "board_webhooks")
if "ix_board_webhooks_board_id" not in webhook_indexes:
op.create_index("ix_board_webhooks_board_id", "board_webhooks", ["board_id"])
if "ix_board_webhooks_enabled" not in webhook_indexes:
op.create_index("ix_board_webhooks_enabled", "board_webhooks", ["enabled"])
if not inspector.has_table("board_webhook_payloads"):
op.create_table(
"board_webhook_payloads",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("webhook_id", sa.Uuid(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("headers", sa.JSON(), nullable=True),
sa.Column("source_ip", sa.String(), nullable=True),
sa.Column("content_type", sa.String(), nullable=True),
sa.Column("received_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(["webhook_id"], ["board_webhooks.id"]),
sa.PrimaryKeyConstraint("id"),
)
inspector = sa.inspect(bind)
payload_indexes = _index_names(inspector, "board_webhook_payloads")
if "ix_board_webhook_payloads_board_id" not in payload_indexes:
op.create_index(
"ix_board_webhook_payloads_board_id",
"board_webhook_payloads",
["board_id"],
)
if "ix_board_webhook_payloads_webhook_id" not in payload_indexes:
op.create_index(
"ix_board_webhook_payloads_webhook_id",
"board_webhook_payloads",
["webhook_id"],
)
if "ix_board_webhook_payloads_received_at" not in payload_indexes:
op.create_index(
"ix_board_webhook_payloads_received_at",
"board_webhook_payloads",
["received_at"],
)
if "ix_board_webhook_payloads_board_webhook_received_at" not in payload_indexes:
op.create_index(
"ix_board_webhook_payloads_board_webhook_received_at",
"board_webhook_payloads",
["board_id", "webhook_id", "received_at"],
)
def downgrade() -> None:
"""Drop board webhook and payload capture tables."""
bind = op.get_bind()
inspector = sa.inspect(bind)
if inspector.has_table("board_webhook_payloads"):
payload_indexes = _index_names(inspector, "board_webhook_payloads")
if "ix_board_webhook_payloads_board_webhook_received_at" in payload_indexes:
op.drop_index(
"ix_board_webhook_payloads_board_webhook_received_at",
table_name="board_webhook_payloads",
)
if "ix_board_webhook_payloads_received_at" in payload_indexes:
op.drop_index(
"ix_board_webhook_payloads_received_at",
table_name="board_webhook_payloads",
)
if "ix_board_webhook_payloads_webhook_id" in payload_indexes:
op.drop_index(
"ix_board_webhook_payloads_webhook_id",
table_name="board_webhook_payloads",
)
if "ix_board_webhook_payloads_board_id" in payload_indexes:
op.drop_index(
"ix_board_webhook_payloads_board_id",
table_name="board_webhook_payloads",
)
op.drop_table("board_webhook_payloads")
inspector = sa.inspect(bind)
if inspector.has_table("board_webhooks"):
webhook_indexes = _index_names(inspector, "board_webhooks")
if "ix_board_webhooks_enabled" in webhook_indexes:
op.drop_index("ix_board_webhooks_enabled", table_name="board_webhooks")
if "ix_board_webhooks_board_id" in webhook_indexes:
op.drop_index("ix_board_webhooks_board_id", table_name="board_webhooks")
op.drop_table("board_webhooks")

View File

@@ -0,0 +1,282 @@
# ruff: noqa: INP001
"""Integration tests for board webhook ingestion behavior."""
from __future__ import annotations
from uuid import UUID, uuid4
import pytest
from fastapi import APIRouter, Depends, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel, col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api import board_webhooks
from app.api.board_webhooks import router as board_webhooks_router
from app.api.deps import get_board_or_404
from app.db.session import get_session
from app.models.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organizations import Organization
async def _make_engine() -> AsyncEngine:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.connect() as conn, conn.begin():
await conn.run_sync(SQLModel.metadata.create_all)
return engine
def _build_test_app(
session_maker: async_sessionmaker[AsyncSession],
) -> FastAPI:
app = FastAPI()
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(board_webhooks_router)
app.include_router(api_v1)
async def _override_get_session() -> AsyncSession:
async with session_maker() as session:
yield session
async def _override_get_board_or_404(
board_id: str,
session: AsyncSession = Depends(get_session),
) -> Board:
board = await Board.objects.by_id(UUID(board_id)).first(session)
if board is None:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return board
app.dependency_overrides[get_session] = _override_get_session
app.dependency_overrides[get_board_or_404] = _override_get_board_or_404
return app
async def _seed_webhook(
session: AsyncSession,
*,
enabled: bool,
) -> tuple[Board, BoardWebhook]:
organization_id = uuid4()
gateway_id = uuid4()
board_id = uuid4()
webhook_id = uuid4()
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
session.add(
Gateway(
id=gateway_id,
organization_id=organization_id,
name="gateway",
url="https://gateway.example.local",
workspace_root="/tmp/workspace",
),
)
board = Board(
id=board_id,
organization_id=organization_id,
gateway_id=gateway_id,
name="Launch board",
slug="launch-board",
description="Board for launch automation.",
)
session.add(board)
session.add(
Agent(
id=uuid4(),
board_id=board_id,
gateway_id=gateway_id,
name="Lead Agent",
status="online",
openclaw_session_id="lead:session:key",
is_board_lead=True,
),
)
webhook = BoardWebhook(
id=webhook_id,
board_id=board_id,
description="Triage payload and create tasks for impacted services.",
enabled=enabled,
)
session.add(webhook)
await session.commit()
return board, webhook
@pytest.mark.asyncio
async def test_ingest_board_webhook_stores_payload_and_notifies_lead(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
app = _build_test_app(session_maker)
sent_messages: list[dict[str, str]] = []
async with session_maker() as session:
board, webhook = await _seed_webhook(session, enabled=True)
async def _fake_optional_gateway_config_for_board(
self: board_webhooks.GatewayDispatchService,
_board: Board,
) -> object:
return object()
async def _fake_try_send_agent_message(
self: board_webhooks.GatewayDispatchService,
*,
session_key: str,
config: object,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
del self, config, deliver
sent_messages.append(
{
"session_key": session_key,
"agent_name": agent_name,
"message": message,
},
)
return None
monkeypatch.setattr(
board_webhooks.GatewayDispatchService,
"optional_gateway_config_for_board",
_fake_optional_gateway_config_for_board,
)
monkeypatch.setattr(
board_webhooks.GatewayDispatchService,
"try_send_agent_message",
_fake_try_send_agent_message,
)
try:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.post(
f"/api/v1/boards/{board.id}/webhooks/{webhook.id}",
json={"event": "deploy", "service": "api"},
headers={"X-Signature": "sha256=abc123"},
)
assert response.status_code == 202
body = response.json()
payload_id = UUID(body["payload_id"])
assert body["board_id"] == str(board.id)
assert body["webhook_id"] == str(webhook.id)
async with session_maker() as session:
payloads = (
await session.exec(
select(BoardWebhookPayload).where(col(BoardWebhookPayload.id) == payload_id),
)
).all()
assert len(payloads) == 1
assert payloads[0].payload == {"event": "deploy", "service": "api"}
assert payloads[0].headers is not None
assert payloads[0].headers.get("x-signature") == "sha256=abc123"
assert payloads[0].headers.get("content-type") == "application/json"
memory_items = (
await session.exec(
select(BoardMemory).where(col(BoardMemory.board_id) == board.id),
)
).all()
assert len(memory_items) == 1
assert memory_items[0].source == "webhook"
assert memory_items[0].tags is not None
assert f"webhook:{webhook.id}" in memory_items[0].tags
assert f"payload:{payload_id}" in memory_items[0].tags
assert f"Payload ID: {payload_id}" in memory_items[0].content
assert len(sent_messages) == 1
assert sent_messages[0]["session_key"] == "lead:session:key"
assert "WEBHOOK EVENT RECEIVED" in sent_messages[0]["message"]
assert str(payload_id) in sent_messages[0]["message"]
assert webhook.description in sent_messages[0]["message"]
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_ingest_board_webhook_rejects_disabled_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
app = _build_test_app(session_maker)
sent_messages: list[str] = []
async with session_maker() as session:
board, webhook = await _seed_webhook(session, enabled=False)
async def _fake_try_send_agent_message(
self: board_webhooks.GatewayDispatchService,
*,
session_key: str,
config: object,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
del self, session_key, config, agent_name, deliver
sent_messages.append(message)
return None
monkeypatch.setattr(
board_webhooks.GatewayDispatchService,
"try_send_agent_message",
_fake_try_send_agent_message,
)
try:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.post(
f"/api/v1/boards/{board.id}/webhooks/{webhook.id}",
json={"event": "deploy"},
)
assert response.status_code == 410
assert response.json() == {"detail": "Webhook is disabled."}
async with session_maker() as session:
stored_payloads = (
await session.exec(
select(BoardWebhookPayload).where(
col(BoardWebhookPayload.board_id) == board.id
),
)
).all()
assert stored_payloads == []
stored_memory = (
await session.exec(
select(BoardMemory).where(col(BoardMemory.board_id) == board.id),
)
).all()
assert stored_memory == []
assert sent_messages == []
finally:
await engine.dispose()

View File

@@ -59,6 +59,8 @@ async def test_delete_my_org_cleans_dependents_before_organization_delete() -> N
"approval_task_links", "approval_task_links",
"approvals", "approvals",
"board_memory", "board_memory",
"board_webhook_payloads",
"board_webhooks",
"board_onboarding_sessions", "board_onboarding_sessions",
"organization_board_access", "organization_board_access",
"organization_invite_board_access", "organization_invite_board_access",

View File

@@ -277,7 +277,9 @@ async def test_update_task_allows_done_from_review_when_review_toggle_enabled()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_rejects_status_change_with_pending_approval_when_toggle_enabled() -> None: async def test_update_task_rejects_status_change_with_pending_approval_when_toggle_enabled() -> (
None
):
engine = await _make_engine() engine = await _make_engine()
try: try:
async with await _make_session(engine) as session: async with await _make_session(engine) as session:
@@ -318,7 +320,9 @@ async def test_update_task_rejects_status_change_with_pending_approval_when_togg
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_allows_status_change_with_pending_approval_when_toggle_disabled() -> None: async def test_update_task_allows_status_change_with_pending_approval_when_toggle_disabled() -> (
None
):
engine = await _make_engine() engine = await _make_engine()
try: try:
async with await _make_session(engine) as session: async with await _make_session(engine) as session:
@@ -353,7 +357,9 @@ async def test_update_task_allows_status_change_with_pending_approval_when_toggl
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> None: async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> (
None
):
engine = await _make_engine() engine = await _make_engine()
try: try:
async with await _make_session(engine) as session: async with await _make_session(engine) as session:

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**). 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: Quick checklist:
- No horizontal scroll - No horizontal scroll
- Primary actions reachable without precision taps - Primary actions reachable without precision taps
- Focus rings visible when tabbing - 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 "./boardSnapshot";
export * from "./boardUpdate"; export * from "./boardUpdate";
export * from "./boardUpdateSuccessMetrics"; 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 "./dashboardKpis";
export * from "./dashboardMetrics"; export * from "./dashboardMetrics";
export * from "./dashboardMetricsApiV1MetricsDashboardGetParams"; export * from "./dashboardMetricsApiV1MetricsDashboardGetParams";
@@ -115,6 +122,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupMemoryRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardWebhookPayloadRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardWebhookRead";
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead"; export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead"; export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead"; export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead";
@@ -133,6 +142,8 @@ export * from "./listBoardMemoryApiV1AgentBoardsBoardIdMemoryGetParams";
export * from "./listBoardMemoryApiV1BoardsBoardIdMemoryGetParams"; export * from "./listBoardMemoryApiV1BoardsBoardIdMemoryGetParams";
export * from "./listBoardsApiV1AgentBoardsGetParams"; export * from "./listBoardsApiV1AgentBoardsGetParams";
export * from "./listBoardsApiV1BoardsGetParams"; export * from "./listBoardsApiV1BoardsGetParams";
export * from "./listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayloadsGetParams";
export * from "./listBoardWebhooksApiV1BoardsBoardIdWebhooksGetParams";
export * from "./listGatewaysApiV1GatewaysGetParams"; export * from "./listGatewaysApiV1GatewaysGetParams";
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams"; export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams"; 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 { useAuth } from "@/auth/clerk";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator"; import { ApiError } from "@/api/mutator";
import { import {
@@ -14,6 +15,14 @@ import {
useGetBoardApiV1BoardsBoardIdGet, useGetBoardApiV1BoardsBoardIdGet,
useUpdateBoardApiV1BoardsBoardIdPatch, useUpdateBoardApiV1BoardsBoardIdPatch,
} from "@/api/generated/boards/boards"; } from "@/api/generated/boards/boards";
import {
getListBoardWebhooksApiV1BoardsBoardIdWebhooksGetQueryKey,
type listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
useCreateBoardWebhookApiV1BoardsBoardIdWebhooksPost,
useDeleteBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdDelete,
useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet,
useUpdateBoardWebhookApiV1BoardsBoardIdWebhooksWebhookIdPatch,
} from "@/api/generated/board-webhooks/board-webhooks";
import { import {
type listBoardGroupsApiV1BoardGroupsGetResponse, type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet, useListBoardGroupsApiV1BoardGroupsGet,
@@ -25,6 +34,7 @@ import {
import { useOrganizationMembership } from "@/lib/use-organization-membership"; import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { import type {
BoardGroupRead, BoardGroupRead,
BoardWebhookRead,
BoardRead, BoardRead,
BoardUpdate, BoardUpdate,
} from "@/api/generated/model"; } from "@/api/generated/model";
@@ -51,8 +61,147 @@ const slugify = (value: string) =>
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board"; .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() { export default function EditBoardPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const params = useParams(); const params = useParams();
@@ -89,6 +238,9 @@ export default function EditBoardPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = 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 onboardingParam = searchParams.get("onboarding");
const searchParamsString = searchParams.toString(); const searchParamsString = searchParams.toString();
@@ -170,6 +322,20 @@ export default function EditBoardPage() {
retry: false, retry: false,
}, },
}); });
const webhooksQuery = useListBoardWebhooksApiV1BoardsBoardIdWebhooksGet<
listBoardWebhooksApiV1BoardsBoardIdWebhooksGetResponse,
ApiError
>(
boardId ?? "",
{ limit: 50 },
{
query: {
enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always",
retry: false,
},
},
);
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({ const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
mutation: { 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(() => { const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return []; if (gatewaysQuery.data?.status !== 200) return [];
@@ -216,6 +434,19 @@ export default function EditBoardPage() {
targetDate ?? toLocalDateInput(baseBoard?.target_date); targetDate ?? toLocalDateInput(baseBoard?.target_date);
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || ""; 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 = const isLoading =
gatewaysQuery.isLoading || gatewaysQuery.isLoading ||
@@ -228,6 +459,8 @@ export default function EditBoardPage() {
groupsQuery.error?.message ?? groupsQuery.error?.message ??
boardQuery.error?.message ?? boardQuery.error?.message ??
null; null;
const webhookErrorMessage =
webhookError ?? webhooksQuery.error?.message ?? null;
const isFormReady = Boolean( const isFormReady = Boolean(
resolvedName.trim() && resolvedDescription.trim() && displayGatewayId, resolvedName.trim() && resolvedDescription.trim() && displayGatewayId,
@@ -250,6 +483,10 @@ export default function EditBoardPage() {
], ],
[groups], [groups],
); );
const webhooks = useMemo<BoardWebhookRead[]>(() => {
if (webhooksQuery.data?.status !== 200) return [];
return webhooksQuery.data.data.items ?? [];
}, [webhooksQuery.data]);
const handleOnboardingConfirmed = (updated: BoardRead) => { const handleOnboardingConfirmed = (updated: BoardRead) => {
setBoard(updated); setBoard(updated);
@@ -294,10 +531,7 @@ export default function EditBoardPage() {
setMetricsError(null); setMetricsError(null);
let parsedMetrics: Record<string, unknown> | null = null; let parsedMetrics: Record<string, unknown> | null = null;
if ( if (resolvedBoardType !== "general" && resolvedSuccessMetrics.trim()) {
resolvedBoardType !== "general" &&
resolvedSuccessMetrics.trim()
) {
try { try {
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record< parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
string, string,
@@ -335,6 +569,74 @@ export default function EditBoardPage() {
updateBoardMutation.mutate({ boardId, data: payload }); 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 ( return (
<> <>
<DashboardPageLayout <DashboardPageLayout
@@ -510,7 +812,9 @@ export default function EditBoardPage() {
<section className="space-y-3 border-t border-slate-200 pt-4"> <section className="space-y-3 border-t border-slate-200 pt-4">
<div> <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"> <p className="text-xs text-slate-600">
Configure board-level workflow enforcement. Configure board-level workflow enforcement.
</p> </p>
@@ -650,6 +954,84 @@ export default function EditBoardPage() {
{isLoading ? "Saving…" : "Save changes"} {isLoading ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </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> </form>
</div> </div>
</DashboardPageLayout> </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", "sm:grid-flow-col sm:auto-cols-[minmax(260px,320px)] sm:grid-cols-none sm:overflow-x-auto",
)} )}
> >
{columns.map((column) => { {columns.map((column) => {
const columnTasks = grouped[column.status] ?? []; const columnTasks = grouped[column.status] ?? [];
const reviewCounts = const reviewCounts =
column.status === "review" column.status === "review"
? columnTasks.reduce( ? columnTasks.reduce(
(acc, task) => { (acc, task) => {
if (task.is_blocked) { if (task.is_blocked) {
acc.blocked += 1; acc.blocked += 1;
return acc;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
return acc; return acc;
}, }
{ if ((task.approvals_pending_count ?? 0) > 0) {
all: columnTasks.length, acc.approval_needed += 1;
approval_needed: 0, return acc;
waiting_lead: 0, }
blocked: 0, acc.waiting_lead += 1;
}, return acc;
) },
: null; {
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
const filteredTasks = const filteredTasks =
column.status === "review" && reviewBucket !== "all" column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => { ? columnTasks.filter((task) => {
if (reviewBucket === "blocked") if (reviewBucket === "blocked") return Boolean(task.is_blocked);
return Boolean(task.is_blocked); if (reviewBucket === "approval_needed")
if (reviewBucket === "approval_needed") return (
return ( (task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
(task.approvals_pending_count ?? 0) > 0 && );
!task.is_blocked if (reviewBucket === "waiting_lead")
); return (
if (reviewBucket === "waiting_lead") !task.is_blocked &&
return ( (task.approvals_pending_count ?? 0) === 0
!task.is_blocked && );
(task.approvals_pending_count ?? 0) === 0 return true;
); })
return true; : columnTasks;
})
: columnTasks;
return ( return (
<div <div
key={column.title} key={column.title}
className={cn( className={cn(
// On mobile, columns are stacked, so avoid forcing tall fixed heights. // On mobile, columns are stacked, so avoid forcing tall fixed heights.
"kanban-column min-h-0", "kanban-column min-h-0",
// On larger screens, keep columns tall to reduce empty space during drag. // On larger screens, keep columns tall to reduce empty space during drag.
"sm:min-h-[calc(100vh-260px)]", "sm:min-h-[calc(100vh-260px)]",
activeColumn === column.status && activeColumn === column.status &&
!readOnly && !readOnly &&
"ring-2 ring-slate-200", "ring-2 ring-slate-200",
)} )}
onDrop={readOnly ? undefined : handleDrop(column.status)} onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)} onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : handleDragLeave(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="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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} /> <span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900"> <h3 className="text-sm font-semibold text-slate-900">
{column.title} {column.title}
</h3> </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>
</div> </div>
{column.status === "review" && reviewCounts ? ( <span
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500"> className={cn(
{( "flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
[ column.badge,
{ key: "all", label: "All", count: reviewCounts.all }, )}
{ >
key: "approval_needed", {filteredTasks.length}
label: "Approval needed", </span>
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>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3"> {column.status === "review" && reviewCounts ? (
<div className="space-y-3"> <div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{filteredTasks.map((task) => { {(
const dueState = resolveDueState(task); [
return ( { key: "all", label: "All", count: reviewCounts.all },
<div key={task.id} ref={setCardRef(task.id)}> {
<TaskCard key: "approval_needed",
title={task.title} label: "Approval needed",
status={task.status} count: reviewCounts.approval_needed,
priority={task.priority} },
assignee={task.assignee ?? undefined} {
due={dueState.due} key: "waiting_lead",
isOverdue={dueState.isOverdue} label: "Lead review",
approvalsPendingCount={task.approvals_pending_count} count: reviewCounts.waiting_lead,
tags={task.tags} },
isBlocked={task.is_blocked} {
blockedByCount={task.blocked_by_task_ids?.length ?? 0} key: "blocked",
onClick={() => onTaskSelect?.(task)} label: "Blocked",
draggable={!readOnly && !task.is_blocked} count: reviewCounts.blocked,
isDragging={draggingId === task.id} },
onDragStart={readOnly ? undefined : handleDragStart(task)} ] as const
onDragEnd={readOnly ? undefined : handleDragEnd} ).map((option) => (
/> <button
</div> 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> </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>
); </div>
})} );
})}
</div> </div>
); );
}); });