Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/jtype-web/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions services/jtype-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pulldown-cmark = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rand_core = { version = "0.6", features = ["std"] }
rust-embed = "8"
hmac = "0.12"
url = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
Expand Down
22 changes: 22 additions & 0 deletions services/jtype-web/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ export const api = {
request<void>(`/api/v1/workspaces/${workspaceId}/kanban/comments/${commentId}`, { method: 'DELETE' }),
getCardActivity: (workspaceId: string, cardId: string) =>
request<KanbanActivityEvent[]>(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/activity`),
listWebhooks: (workspaceId: string) =>
request<KanbanWebhook[]>(`/api/v1/workspaces/${workspaceId}/kanban/webhooks`),
createWebhook: (workspaceId: string, data: { name: string; targetUrl: string; boardId?: string | null; eventTypes: string[] }) =>
request<KanbanWebhookCreated>(`/api/v1/workspaces/${workspaceId}/kanban/webhooks`, { method: 'POST', body: JSON.stringify(data) }),
deleteWebhook: (workspaceId: string, webhookId: string) =>
request<void>(`/api/v1/workspaces/${workspaceId}/kanban/webhooks/${webhookId}`, { method: 'DELETE' }),

listLabels: (workspaceId: string, boardId: string) =>
request<KanbanLabel[]>(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/labels`),
Expand Down Expand Up @@ -835,6 +841,22 @@ export interface KanbanBoardFull extends KanbanBoardSummary {
labels: KanbanLabel[]
}

export interface KanbanWebhook {
id: string
boardId?: string | null
name: string
targetUrl: string
eventTypes: string[]
enabled: boolean
secretMasked: string
lastDeliveryAt?: string | null
lastStatus?: string | null
createdAt: string
}
export interface KanbanWebhookCreated extends KanbanWebhook {
secret: string
}

export interface KanbanComment {
id: string
cardId: string
Expand Down
97 changes: 96 additions & 1 deletion services/jtype-web/frontend/src/pages/Kanban.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Dialog, DialogPanel, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { PlusIcon, EllipsisHorizontalIcon, TrashIcon, ArchiveBoxIcon, ArrowUturnLeftIcon, TagIcon, XMarkIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
import { PlusIcon, EllipsisHorizontalIcon, TrashIcon, ArchiveBoxIcon, ArrowUturnLeftIcon, TagIcon, XMarkIcon, ChevronDownIcon, BoltIcon } from '@heroicons/react/24/outline'
import {
api,
setSessionId,
Expand All @@ -12,6 +12,8 @@ import {
type KanbanLabel,
type KanbanTrashItem,
type KanbanPriority,
type KanbanWebhook,
type KanbanWebhookCreated,
type UpdateKanbanCardRequest,
type MemberInfo,
} from '../api'
Expand Down Expand Up @@ -41,6 +43,7 @@ export function Kanban() {
const [error, setError] = useState('')
const [showLabels, setShowLabels] = useState(false)
const [showTrash, setShowTrash] = useState(false)
const [showWebhooks, setShowWebhooks] = useState(false)
const [view, setView] = useState<Partial<BoardViewConfig>>({})

const { sessionId: wsSessionId, subscribe: wsSubscribe, status: wsStatus } = useWorkspaceSocket(workspaceId)
Expand Down Expand Up @@ -373,6 +376,7 @@ export function Kanban() {
<div className="flex items-center gap-1.5">
<button onClick={() => setShowLabels(true)} className="sidebar-action" title="Manage labels"><TagIcon className="h-4 w-4" /></button>
<button onClick={() => setShowTrash(true)} className="sidebar-action" title="Archived cards"><ArchiveBoxIcon className="h-4 w-4" /></button>
<button onClick={() => setShowWebhooks(true)} className="sidebar-action" title="Webhooks"><BoltIcon className="h-4 w-4" /></button>
<Menu as="div" className="relative">
<MenuButton className="sidebar-action px-2"><EllipsisHorizontalIcon className="h-4 w-4" /></MenuButton>
<MenuItems className="absolute right-0 z-20 mt-1 w-48 rounded-xl border border-black/[0.06] bg-white p-1 shadow-lg focus:outline-none">
Expand Down Expand Up @@ -413,6 +417,9 @@ export function Kanban() {
{showTrash && board && workspaceId && (
<TrashDialog workspaceId={workspaceId} boardId={board.id} onClose={() => setShowTrash(false)} onChanged={reload} />
)}
{showWebhooks && workspaceId && (
<WebhooksDialog workspaceId={workspaceId} boardId={board?.id ?? null} onClose={() => setShowWebhooks(false)} />
)}

<button onClick={() => navigate(`/workspaces/${workspaceId}`)} className="absolute bottom-4 left-4 rounded-full bg-white px-3 py-1.5 text-xs font-medium text-zinc-500 shadow ring-1 ring-black/[0.05] hover:text-brand">
← Workspace
Expand Down Expand Up @@ -520,3 +527,91 @@ function TrashDialog(props: { workspaceId: string; boardId: string; onClose: ()
</Dialog>
)
}

const WEBHOOK_EVENTS = ['kanban:card-updated', 'kanban:card-archived', '*']

function WebhooksDialog(props: { workspaceId: string; boardId: string | null; onClose: () => void }) {
const { workspaceId, boardId, onClose } = props
const [hooks, setHooks] = useState<KanbanWebhook[]>([])
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [scope, setScope] = useState<'all' | 'board'>('all')
const [events, setEvents] = useState<string[]>(['kanban:card-updated'])
const [revealed, setRevealed] = useState<KanbanWebhookCreated | null>(null)
const [error, setError] = useState('')

const load = useCallback(() => {
api.kanban.listWebhooks(workspaceId).then(setHooks).catch((e) => setError(String(e)))
}, [workspaceId])
useEffect(() => { load() }, [load])

const toggleEvent = (e: string) => setEvents((prev) => (prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e]))

const create = async () => {
if (!name.trim() || !url.trim() || events.length === 0) return
try {
const created = await api.kanban.createWebhook(workspaceId, {
name: name.trim(),
targetUrl: url.trim(),
boardId: scope === 'board' ? boardId : null,
eventTypes: events,
})
setRevealed(created); setName(''); setUrl(''); setError(''); load()
} catch (e) { setError(String(e)) }
}
const remove = async (id: string) => {
try { await api.kanban.deleteWebhook(workspaceId, id); load() } catch (e) { setError(String(e)) }
}

return (
<Dialog open onClose={onClose} className="relative z-30">
<div className="fixed inset-0 bg-black/20" aria-hidden />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="w-full max-w-lg rounded-2xl bg-white p-5 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-zinc-800"><BoltIcon className="h-4 w-4" /> Webhooks</h2>
<button onClick={onClose} className="rounded p-1 text-zinc-400 hover:bg-zinc-100"><XMarkIcon className="h-4 w-4" /></button>
</div>
{error && <p className="mb-2 text-xs text-red-600">{error}</p>}
{revealed && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
<div className="font-medium">Signing secret — shown once, copy it now:</div>
<code className="break-all">{revealed.secret}</code>
</div>
)}
<ul className="mb-3 max-h-48 space-y-1.5 overflow-auto">
{hooks.map((h) => (
<li key={h.id} className="flex items-center gap-2 rounded-lg border border-zinc-100 p-2 text-xs">
<div className="min-w-0 flex-1">
<div className="font-medium text-zinc-800">{h.name}</div>
<div className="truncate text-zinc-500">{h.targetUrl}</div>
<div className="text-zinc-400">{h.eventTypes.join(', ')}{h.boardId ? ' · this board' : ' · all boards'}{h.lastStatus ? ` · last: ${h.lastStatus}` : ''}</div>
</div>
<button onClick={() => remove(h.id)} className="rounded p-1 text-zinc-400 hover:text-red-600" title="Delete"><TrashIcon className="h-4 w-4" /></button>
</li>
))}
{hooks.length === 0 && <li className="text-xs text-zinc-400">No webhooks yet.</li>}
</ul>
<form onSubmit={(e) => { e.preventDefault(); void create() }} className="space-y-2 border-t border-zinc-100 pt-3">
<input className="w-full rounded-lg border border-zinc-200 px-2 py-1.5 text-sm" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="w-full rounded-lg border border-zinc-200 px-2 py-1.5 text-sm" placeholder="https://example.com/hook" value={url} onChange={(e) => setUrl(e.target.value)} />
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-600">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev} className="inline-flex items-center gap-1">
<input type="checkbox" checked={events.includes(ev)} onChange={() => toggleEvent(ev)} /> {ev}
</label>
))}
</div>
<div className="flex items-center gap-3 text-xs text-zinc-600">
<label className="inline-flex items-center gap-1"><input type="radio" checked={scope === 'all'} onChange={() => setScope('all')} /> All boards</label>
{boardId && <label className="inline-flex items-center gap-1"><input type="radio" checked={scope === 'board'} onChange={() => setScope('board')} /> This board</label>}
</div>
<div className="flex justify-end">
<button type="submit" className="rounded-lg bg-brand px-3 py-1.5 text-xs font-medium text-white">Add webhook</button>
</div>
</form>
</DialogPanel>
</div>
</Dialog>
)
}
2 changes: 2 additions & 0 deletions services/jtype-web/migrations/0017_kanban_webhooks.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS kanban_webhook_deliveries;
DROP TABLE IF EXISTS kanban_webhooks;
42 changes: 42 additions & 0 deletions services/jtype-web/migrations/0017_kanban_webhooks.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
CREATE TABLE kanban_webhooks (
id CHAR(36) NOT NULL,
workspace_id CHAR(36) NOT NULL,
board_id CHAR(36) NULL,
name VARCHAR(160) NOT NULL,
target_url VARCHAR(2048) NOT NULL,
secret CHAR(64) NOT NULL,
event_types JSON NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_by_user_id CHAR(36) NOT NULL,
last_delivery_at TIMESTAMP NULL,
last_status VARCHAR(32) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_webhooks_workspace (workspace_id),
KEY idx_webhooks_enabled (workspace_id, enabled),
CONSTRAINT kanban_webhooks_workspace_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
CONSTRAINT kanban_webhooks_board_fk FOREIGN KEY (board_id) REFERENCES kanban_boards(id) ON DELETE CASCADE,
CONSTRAINT kanban_webhooks_creator_fk FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE kanban_webhook_deliveries (
id CHAR(36) NOT NULL,
webhook_id CHAR(36) NOT NULL,
workspace_id CHAR(36) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status ENUM('pending','succeeded','failed','dead') NOT NULL DEFAULT 'pending',
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 6,
last_status_code INT NULL,
last_error VARCHAR(512) NULL,
next_retry_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_deliveries_webhook (webhook_id),
KEY idx_deliveries_due (status, next_retry_at),
CONSTRAINT kanban_deliveries_webhook_fk FOREIGN KEY (webhook_id) REFERENCES kanban_webhooks(id) ON DELETE CASCADE,
CONSTRAINT kanban_deliveries_workspace_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
6 changes: 6 additions & 0 deletions services/jtype-web/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ fn all_migrations() -> Vec<Migration> {
up: include_str!("../../migrations/0016_kanban_comments.up.sql"),
down: include_str!("../../migrations/0016_kanban_comments.down.sql"),
},
Migration {
version: 17,
name: "kanban_webhooks",
up: include_str!("../../migrations/0017_kanban_webhooks.up.sql"),
down: include_str!("../../migrations/0017_kanban_webhooks.down.sql"),
},
]
}

Expand Down
27 changes: 27 additions & 0 deletions services/jtype-web/src/handlers/kanban/card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,15 @@ pub async fn create_card(
)
.await;

super::webhook::enqueue_event(
&state.pool,
&workspace_id,
&board_id,
"kanban:card-updated",
json!({ "event": "kanban:card-updated", "cardId": card_id, "boardId": board_id }),
)
.await;

Comment on lines +277 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Webhook enqueue is non-atomic with the card mutation commit.

These paths commit the card change first, then enqueue webhook delivery rows. A crash/restart in that window persists the card mutation but drops the webhook event, breaking event consistency.

Suggested direction
-    tx.commit().await?;
-
-    super::webhook::enqueue_event(&state.pool, ...).await;
+    super::webhook::enqueue_event_tx(&mut tx, ...).await?;
+    tx.commit().await?;

Apply the same transactional outbox pattern to create/move/archive so state change and event enqueue succeed or fail together.

Also applies to: 733-741, 874-882

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/jtype-web/src/handlers/kanban/card.rs` around lines 277 - 285, The
webhook event enqueue via the enqueue_event function occurs after the card
mutation is committed, creating a non-atomic operation. Wrap both the card
update operation and the enqueue_event call within the same database transaction
so they commit together atomically. Apply this same transactional pattern to all
three locations where this issue occurs (the card update, move, and archive
paths) to ensure that if either the state change or event enqueue fails, both
are rolled back together.

// Re-fetch with DB timestamps
fetch_card_response(&state, &workspace_id, &card_id).await
}
Expand Down Expand Up @@ -721,6 +730,15 @@ pub async fn move_card(
)
.await;

super::webhook::enqueue_event(
&state.pool,
&workspace_id,
&card.board_id,
"kanban:card-updated",
json!({ "event": "kanban:card-updated", "cardId": card.id, "boardId": card.board_id }),
)
.await;

Ok(Json(card).into_response())
}

Expand Down Expand Up @@ -853,6 +871,15 @@ pub async fn archive_card(
)
.await;

super::webhook::enqueue_event(
&state.pool,
&workspace_id,
&board_id,
"kanban:card-archived",
json!({ "event": "kanban:card-archived", "cardId": card_id, "boardId": board_id }),
)
.await;

Ok(Json(json!({
"id": trash_id,
"cardId": card_id,
Expand Down
1 change: 1 addition & 0 deletions services/jtype-web/src/handlers/kanban/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod card;
pub mod column;
pub mod comment;
pub mod label;
pub mod webhook;

use sqlx::Row;

Expand Down
Loading
Loading