Files
kandev/apps/web/components/jira/jira-issue-watch-table.tsx
T
João Salavisa 5693c10d8c feat(jira): poll JQL queries to auto-create tasks (issue watchers) (#746)
* feat(jira): poll JQL queries to auto-create tasks (issue watchers)

* fix(jira): address review feedback on issue watcher

- Poller: gate per-watch by `LastPolledAt` + `PollIntervalSeconds` so the
  user-configured cadence actually applies (was silently ignored, every
  watch polled every 5 min). Tick interval reduced to 60s to match the
  smallest valid per-watch interval.
- Service: validate `pollIntervalSeconds` against the documented 60-3600s
  bounds on create/update so the UI's range check is enforced server-side.
- Service: stamp `last_polled_at` via a fresh `context.Background()` with a
  short write deadline; the poller's ctx may be cancelled at shutdown,
  which would otherwise drop the timestamp on the floor.
- Service/store: replace per-ticket `HasIssueWatchTask` round-trips with a
  single `ListSeenIssueKeys` set lookup (up to 50× fewer DB queries per
  tick on broad JQL queries).
- Hook: reset cached watch list on workspace switch — the store's `loaded`
  flag is global, so without this the table renders the previous
  workspace's watchers stale.
- Section component: pull the CRUD callbacks from a single
  `useJiraIssueWatches` call and pass them down, instead of invoking the
  hook twice on the same workspace.
- Dialog: add the three placeholders (`{{issue.type}}`, `{{issue.reporter}}`,
  `{{issue.project}}`) supported by the backend interpolator but missing
  from the help text.

* fix(jira): repair workspace-switch refetch + drop double trigger toast

The previous a9bd9fa attempt at the workspace-switch reset was incomplete:
`setJiraIssueWatches([])` flips `loaded=true`, so the fetch effect's
`if (!workspaceId || loaded || loading) return` guard still short-circuited
and the new workspace's data was never fetched. Switching workspaces left
the table empty with no recovery path.

- Add a dedicated `resetJiraIssueWatches` action that zeroes both `items`
  and `loaded`, and use it from the hook's workspace-change effect.
- Drop the inline "Checking JIRA…" toast in the watcher table — the
  parent's `wrappedTrigger` already fires a result toast on completion,
  so the click was double-toasting.

* fix(jira): reject empty workflowId/workflowStepId on issue-watch PATCH

`applyIssueWatchPatch` writes the field whenever the patch pointer is
non-nil, but Go's JSON decoder returns a non-nil pointer to "" for an
explicit `{"workflowId": ""}` body. The post-patch JQL check guarded
against the same hole for `jql`; this extends it to the workflow
fields. Without it, a stripped row would silently drop every event the
orchestrator tries to handle (the watcher looks active but produces no
tasks).

* fix(jira): require workspace ownership on issue-watch mutate/trigger

The PATCH/DELETE/trigger handlers looked up watches by the path :id with
no workspace check, while the list/create handlers correctly required
workspace_id. A user authenticated to workspace A who learned a watch
UUID from workspace B could mutate it, delete it, or trigger it
(running JQL with workspace B's stored credentials and creating tasks
there). Closes the IDOR by:

- backend: PATCH/DELETE/trigger now require ?workspace_id=... and 404
  on mismatch (404 not 403, so probing can't enumerate IDs across
  workspaces).
- frontend: api client + hook thread workspaceId through all mutation
  calls.
- store: drop the no-longer-used HasIssueWatchTask method (replaced by
  ListSeenIssueKeys after the N+1 fix); test now asserts via the
  bulk-fetched set.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-01 11:01:02 +01:00

157 lines
4.8 KiB
TypeScript

"use client";
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { JiraIssueWatch } from "@/lib/types/jira";
type JiraIssueWatchTableProps = {
watches: JiraIssueWatch[];
onEdit: (watch: JiraIssueWatch) => void;
onDelete: (id: string) => void;
onTrigger: (id: string) => void;
onToggleEnabled: (watch: JiraIssueWatch) => void;
};
function formatLastPolled(dateStr?: string | null): string {
if (!dateStr) return "Never";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function WatchActions({
watch,
onToggleEnabled,
onTrigger,
onDelete,
}: {
watch: JiraIssueWatch;
onToggleEnabled: (watch: JiraIssueWatch) => void;
onTrigger: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onToggleEnabled(watch);
}}
>
{watch.enabled ? (
<IconPlayerPause className="h-3.5 w-3.5" />
) : (
<IconPlayerPlay className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{watch.enabled ? "Pause" : "Enable"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
// Toast is fired by the parent's wrappedTrigger on completion —
// the inline "Checking…" toast it used to fire here was a duplicate.
onTrigger(watch.id);
}}
>
<IconRefresh className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Check now</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(watch.id);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
);
}
export function JiraIssueWatchTable({
watches,
onEdit,
onDelete,
onTrigger,
onToggleEnabled,
}: JiraIssueWatchTableProps) {
if (watches.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
No JIRA watchers configured. Create one to auto-create tasks from JQL queries.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>JQL</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Last Polled</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{watches.map((watch) => (
<TableRow key={watch.id} className="cursor-pointer" onClick={() => onEdit(watch)}>
<TableCell className="font-mono text-xs max-w-md truncate" title={watch.jql}>
{watch.jql}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{Math.round(watch.pollIntervalSeconds / 60)}m
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatLastPolled(watch.lastPolledAt)}
</TableCell>
<TableCell>
<Badge variant={watch.enabled ? "default" : "secondary"} className="text-xs">
{watch.enabled ? "Active" : "Paused"}
</Badge>
</TableCell>
<TableCell className="text-right">
<WatchActions
watch={watch}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onDelete={onDelete}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}