feat: add board webhook configuration and payload models
This commit is contained in:
451
backend/app/api/board_webhooks.py
Normal file
451
backend/app/api/board_webhooks.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
32
backend/app/models/board_webhook_payloads.py
Normal file
32
backend/app/models/board_webhook_payloads.py
Normal 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)
|
||||||
26
backend/app/models/board_webhooks.py
Normal file
26
backend/app/models/board_webhooks.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
|||||||
61
backend/app/schemas/board_webhooks.py
Normal file
61
backend/app/schemas/board_webhooks.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
282
backend/tests/test_board_webhooks_api.py
Normal file
282
backend/tests/test_board_webhooks_api.py
Normal 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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1829
frontend/src/api/generated/board-webhooks/board-webhooks.ts
Normal file
1829
frontend/src/api/generated/board-webhooks/board-webhooks.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/src/api/generated/model/boardWebhookCreate.ts
Normal file
15
frontend/src/api/generated/model/boardWebhookCreate.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
22
frontend/src/api/generated/model/boardWebhookPayloadRead.ts
Normal file
22
frontend/src/api/generated/model/boardWebhookPayloadRead.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
20
frontend/src/api/generated/model/boardWebhookRead.ts
Normal file
20
frontend/src/api/generated/model/boardWebhookRead.ts
Normal 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;
|
||||||
|
}
|
||||||
14
frontend/src/api/generated/model/boardWebhookUpdate.ts
Normal file
14
frontend/src/api/generated/model/boardWebhookUpdate.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user