From f1dc9f0c0dfcb9e59b5df2b582f7dc1875d84446 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:52:22 -0500 Subject: [PATCH 1/2] fix(github): improve Dependabot alert counting with state filtering (#1998) Co-authored-by: Tofik Hasanov --- .../src/manifests/github/checks/dependabot.ts | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/packages/integration-platform/src/manifests/github/checks/dependabot.ts b/packages/integration-platform/src/manifests/github/checks/dependabot.ts index 08526b4f6..98828b9c4 100644 --- a/packages/integration-platform/src/manifests/github/checks/dependabot.ts +++ b/packages/integration-platform/src/manifests/github/checks/dependabot.ts @@ -62,29 +62,37 @@ export const dependabotCheck: IntegrationCheck = { */ const fetchAlertCounts = async (repoFullName: string): Promise => { try { - const alerts = await ctx.fetchAllPages( - `/repos/${repoFullName}/dependabot/alerts`, - ); + // GitHub supports filtering by state: open, fixed, dismissed (no "all") + const [openAlerts, fixedAlerts, dismissedAlerts] = await Promise.all([ + ctx.fetchWithLinkHeader( + `/repos/${repoFullName}/dependabot/alerts`, + { + params: { state: 'open', per_page: '100' }, + }, + ), + ctx.fetchWithLinkHeader( + `/repos/${repoFullName}/dependabot/alerts`, + { + params: { state: 'fixed', per_page: '100' }, + }, + ), + ctx.fetchWithLinkHeader( + `/repos/${repoFullName}/dependabot/alerts`, + { params: { state: 'dismissed', per_page: '100' } }, + ), + ]); const counts: AlertCounts = { - open: 0, - dismissed: 0, - fixed: 0, - total: alerts.length, + open: openAlerts.length, + dismissed: dismissedAlerts.length, + fixed: fixedAlerts.length, + total: openAlerts.length + fixedAlerts.length + dismissedAlerts.length, bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, }; - for (const alert of alerts) { - // Count by state - if (alert.state === 'open') counts.open++; - else if (alert.state === 'dismissed') counts.dismissed++; - else if (alert.state === 'fixed') counts.fixed++; - - // Count open alerts by severity - if (alert.state === 'open') { - const severity = alert.security_vulnerability?.severity ?? 'low'; - counts.bySeverity[severity]++; - } + for (const alert of openAlerts) { + const severity = alert.security_vulnerability?.severity ?? 'low'; + counts.bySeverity[severity]++; } return counts; @@ -95,6 +103,13 @@ export const dependabotCheck: IntegrationCheck = { ctx.log(`Cannot access Dependabot alerts for ${repoFullName} (permission denied)`); return null; } + // 400 can mean Dependabot alerts endpoint isn't available for the repo/app + if (errorStr.includes('400') || errorStr.includes('Bad Request')) { + ctx.log( + `Dependabot alerts not available for ${repoFullName} (feature may not be enabled)`, + ); + return null; + } ctx.warn(`Failed to fetch Dependabot alerts for ${repoFullName}: ${errorStr}`); return null; } From 83595bbc4211d3a46ba4a57cf523ad04af89b230 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:59:23 -0500 Subject: [PATCH 2/2] CS-91 [Feature] Bulk Change Task Statuses (#1993) * feat(app): added UI for bulk change of task status * feat(api): create endpoint to bulk-update task statuses * feat(app): create modal for bulk change of multiple tasks * fix(app): select all state compares lengths not actual IDs in Evidence page * fix(app): dialog text incorrectly pluralizes single item selection for bulk task status change * fix(api): null reviewDate silently sets date to 1970 epoch * fix(app): change 'Move' to 'Change Status' on Bulk Status Change modal --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- apps/api/src/tasks/tasks.controller.ts | 79 ++++++++++ apps/api/src/tasks/tasks.service.ts | 40 +++++- .../vendor/vendor-risk-assessment-task.ts | 21 ++- .../components/BulkTaskStatusChangeModal.tsx | 132 +++++++++++++++++ .../components/ModernSingleStatusTaskList.tsx | 122 ++++++++++++++++ .../tasks/components/ModernTaskList.tsx | 90 +----------- .../tasks/components/ModernTaskListItem.tsx | 136 ++++++++++++++++++ .../tasks/components/TaskBulkActions.tsx | 63 ++++++++ packages/docs/openapi.json | 90 ++++++++++++ 9 files changed, 684 insertions(+), 89 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskListItem.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/components/TaskBulkActions.tsx diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 2d3aac447..19dd8fb41 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -6,11 +6,13 @@ import { Delete, Get, Param, + Patch, Post, UseGuards, } from '@nestjs/common'; import { ApiExtraModels, + ApiBody, ApiHeader, ApiOperation, ApiParam, @@ -18,6 +20,7 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; +import { TaskStatus } from '@db'; import { AttachmentsService } from '../attachments/attachments.service'; import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; @@ -144,6 +147,82 @@ export class TasksController { return await this.tasksService.getTask(organizationId, taskId); } + @Patch('bulk') + @ApiOperation({ + summary: 'Update status for multiple tasks', + description: 'Bulk update the status of multiple tasks', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + taskIds: { + type: 'array', + items: { type: 'string' }, + example: ['tsk_abc123', 'tsk_def456'], + }, + status: { + type: 'string', + enum: Object.values(TaskStatus), + example: TaskStatus.in_progress, + }, + reviewDate: { + type: 'string', + format: 'date-time', + example: '2025-01-01T00:00:00.000Z', + description: 'Optional review date to set on all tasks', + }, + }, + required: ['taskIds', 'status'], + }, + }) + @ApiResponse({ + status: 200, + description: 'Tasks updated successfully', + schema: { + type: 'object', + properties: { + updatedCount: { type: 'number', example: 2 }, + }, + }, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request body', + }) + async updateTasksStatus( + @OrganizationId() organizationId: string, + @Body() + body: { + taskIds: string[]; + status: TaskStatus; + reviewDate?: string; + }, + ): Promise<{ updatedCount: number }> { + const { taskIds, status, reviewDate } = body; + + if (!Array.isArray(taskIds) || taskIds.length === 0) { + throw new BadRequestException('taskIds must be a non-empty array'); + } + + if (!Object.values(TaskStatus).includes(status)) { + throw new BadRequestException('status is invalid'); + } + + let parsedReviewDate: Date | undefined; + if (reviewDate !== undefined) { + if (reviewDate === null || typeof reviewDate !== 'string') { + throw new BadRequestException('reviewDate is invalid'); + } + parsedReviewDate = new Date(reviewDate); + if (Number.isNaN(parsedReviewDate.getTime())) { + throw new BadRequestException('reviewDate is invalid'); + } + } + + return await this.tasksService.updateTasksStatus(organizationId, taskIds, status, parsedReviewDate); + } + // ==================== TASK ATTACHMENTS ==================== @Get(':taskId/attachments') diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 12c6d06a5..61c4e74f7 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -3,7 +3,7 @@ import { Injectable, InternalServerErrorException, } from '@nestjs/common'; -import { db } from '@trycompai/db'; +import { db, TaskStatus } from '@trycompai/db'; import { TaskResponseDto } from './dto/task-responses.dto'; @Injectable() @@ -113,4 +113,42 @@ export class TasksService { return runs; } + + /** + * Update status for multiple tasks + */ + async updateTasksStatus( + organizationId: string, + taskIds: string[], + status: TaskStatus, + reviewDate?: Date, + ): Promise<{ updatedCount: number }> { + try { + const result = await db.task.updateMany({ + where: { + id: { + in: taskIds, + }, + organizationId, + }, + data: { + status, + updatedAt: new Date(), + ...(reviewDate !== undefined ? { reviewDate } : {}), + }, + }); + + if (result.count === 0) { + throw new BadRequestException('No tasks were updated. Check task IDs or organization access.'); + } + + return { updatedCount: result.count }; + } catch (error) { + console.error('Error updating task statuses:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to update task statuses'); + } + } } diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts index cfa7aee7e..96ad4e5bd 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts @@ -6,7 +6,9 @@ import { type TaskItemEntityType, } from '@db'; import type { Prisma } from '@prisma/client'; +import type { Task } from '@trigger.dev/sdk'; import { logger, queue, schemaTask } from '@trigger.dev/sdk'; +import type { z } from 'zod'; import { resolveTaskCreatorAndAssignee } from './vendor-risk-assessment/assignee'; import { VENDOR_RISK_ASSESSMENT_TASK_ID } from './vendor-risk-assessment/constants'; @@ -20,6 +22,19 @@ import { vendorRiskAssessmentPayloadSchema } from './vendor-risk-assessment/sche const VERIFY_RISK_ASSESSMENT_TASK_TITLE = 'Verify risk assessment' as const; +type VendorRiskAssessmentResult = { + success: true; + vendorId: string; + deduped: boolean; + researched: boolean; + skipped?: boolean; + reason?: 'no_website' | 'invalid_website'; + riskAssessmentVersion: string | null; + verifyTaskItemId?: string; +}; + +type VendorRiskAssessmentTaskInput = z.input; + function parseVersionNumber(version: string | null | undefined): number { if (!version || !version.startsWith('v')) return 0; const n = Number.parseInt(version.slice(1), 10); @@ -185,7 +200,11 @@ function normalizeWebsite(website: string): string | null { } } -export const vendorRiskAssessmentTask = schemaTask({ +export const vendorRiskAssessmentTask: Task< + typeof VENDOR_RISK_ASSESSMENT_TASK_ID, + VendorRiskAssessmentTaskInput, + VendorRiskAssessmentResult +> = schemaTask({ id: VENDOR_RISK_ASSESSMENT_TASK_ID, queue: queue({ name: 'vendor-risk-assessment', concurrencyLimit: 10 }), schema: vendorRiskAssessmentPayloadSchema, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx new file mode 100644 index 000000000..a43dc8c11 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { Label } from '@comp/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; +import { TaskStatus } from '@db'; +import { Loader2 } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; +import { apiClient } from '@/lib/api-client'; +import { toast } from 'sonner'; +import { TaskStatusIndicator } from './TaskStatusIndicator'; + +interface BulkTaskStatusChangeModalProps { + open: boolean; + selectedTaskIds: string[]; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function BulkTaskStatusChangeModal({ + open, + onOpenChange, + selectedTaskIds, + onSuccess, +}: BulkTaskStatusChangeModalProps) { + const router = useRouter(); + const params = useParams<{ orgId: string }>(); + const orgIdParam = Array.isArray(params.orgId) ? params.orgId[0] : params.orgId; + + const statusOptions = useMemo(() => Object.values(TaskStatus) as TaskStatus[], []); + const defaultStatus = statusOptions[0]; + + const [status, setStatus] = useState(defaultStatus); + const [isSubmitting, setIsSubmitting] = useState(false); + const selectedCount = selectedTaskIds.length; + const isSingular = selectedCount === 1; + + useEffect(() => { + if (open) { + setStatus(defaultStatus); + } + }, [defaultStatus, open]); + + const handleMove = async () => { + if (!orgIdParam || selectedTaskIds.length === 0) { + return; + } + + try { + setIsSubmitting(true); + const payload = { + taskIds: selectedTaskIds, + status, + ...(status === TaskStatus.done ? { reviewDate: new Date().toISOString() } : {}), + }; + + const response = await apiClient.patch<{ updatedCount: number }>( + '/v1/tasks/bulk', + payload, + orgIdParam, + ); + + if (response.error) { + throw new Error(response.error); + } + + const updatedCount = response.data?.updatedCount ?? selectedTaskIds.length; + toast.success(`Updated ${updatedCount} task${updatedCount === 1 ? '' : 's'}`); + onSuccess?.(); + onOpenChange(false); + router.refresh(); + } catch (error) { + console.error('Failed to bulk update task status', error); + const message = error instanceof Error ? error.message : 'Failed to update tasks'; + toast.error(message); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + Bulk Status Update + + {`${selectedCount} item${isSingular ? '' : 's'} ${ + isSingular ? 'is' : 'are' + } selected. Are you sure you want to change the status?`} + + + +
+ + +
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx new file mode 100644 index 000000000..d845f67af --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import { Member, Task, User } from '@db'; +import { Checkbox } from '@comp/ui/checkbox'; +import { ModernTaskListItem } from './ModernTaskListItem'; +import { TaskBulkActions } from './TaskBulkActions'; + +interface ModernSingleStatusTaskListProps { + config: { + icon: any; + label: string; + color: string; + }; + tasks: (Task & { + controls?: { id: string; name: string }[]; + evidenceAutomations?: Array<{ + id: string; + isEnabled: boolean; + name: string; + runs?: Array<{ + status: string; + success: boolean | null; + evaluationStatus: string | null; + createdAt: Date; + triggeredBy: string; + runDuration: number | null; + }>; + }>; + })[]; + members: (Member & { user: User })[]; + handleTaskClick: (taskId: string) => void; +} + +export function ModernSingleStatusTaskList({ config, tasks, members, handleTaskClick }: ModernSingleStatusTaskListProps) { + const [selectable, setSelectable] = useState(false); + const [selectedTaskIds, setSelectedTaskIds] = useState([]); + const StatusIcon = config.icon; + + useEffect(() => { + if (!selectable) { + setSelectedTaskIds([]); + } + }, [selectable]); + + // Remove stale selections when the visible task list changes + useEffect(() => { + const visibleIds = new Set(tasks.map((task) => task.id)); + setSelectedTaskIds((prev) => prev.filter((id) => visibleIds.has(id))); + }, [tasks]); + + const visibleIds = useMemo(() => new Set(tasks.map((task) => task.id)), [tasks]); + const visibleSelectedCount = selectedTaskIds.filter((id) => visibleIds.has(id)).length; + const allSelected = tasks.length > 0 && visibleSelectedCount === tasks.length; + const noneSelected = visibleSelectedCount === 0; + const someSelected = !noneSelected && !allSelected; + + const selectAllChecked = useMemo(() => { + if (allSelected) return true; + if (someSelected) return 'indeterminate'; + return false; + }, [allSelected, someSelected]); + + const handleSelectAllChange = (checked: boolean | 'indeterminate') => { + if (checked === true) { + setSelectedTaskIds(tasks.map((task) => task.id)); + return; + } + setSelectedTaskIds([]); + }; + + const handleSelect = (taskId: string, checked: boolean) => { + setSelectedTaskIds((prev) => { + if (checked) return Array.from(new Set([...prev, taskId])); + return prev.filter((id) => id !== taskId); + }); + }; + + return ( +
+
+ +

+ {config.label} +

+ ({tasks.length}) + { + setSelectedTaskIds([]); + setSelectable(false); + }} + /> +
+
+ {selectable ? ( +
+ + Select all +
+ ) : null} + {tasks.map((task) => ( + + ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskList.tsx index 91b82456d..f9211bab1 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskList.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskList.tsx @@ -1,12 +1,10 @@ 'use client'; -import type { Control, EvidenceAutomation, EvidenceAutomationRun, Member, Task, User } from '@db'; +import type { Member, Task, User } from '@db'; import { Check, Circle, Loader2, XCircle } from 'lucide-react'; -import Image from 'next/image'; import { usePathname, useRouter } from 'next/navigation'; import { useMemo } from 'react'; -import { AutomationIndicator } from './AutomationIndicator'; -import { TaskStatusSelector } from './TaskStatusSelector'; +import { ModernSingleStatusTaskList } from './ModernSingleStatusTaskList'; interface ModernTaskListProps { tasks: (Task & { @@ -65,11 +63,6 @@ export function ModernTaskList({ tasks, members, statusFilter }: ModernTaskListP return grouped; }, [tasks]); - const assignedMember = (task: Task) => { - if (!task.assigneeId) return null; - return members.find((m) => m.id === task.assigneeId); - }; - const handleTaskClick = (taskId: string) => { router.push(`${pathname}/${taskId}`); }; @@ -99,86 +92,9 @@ export function ModernTaskList({ tasks, members, statusFilter }: ModernTaskListP if (statusTasks.length === 0) return null; const config = statusConfig[status]; - const StatusIcon = config.icon; return ( -
-
- -

- {config.label} -

- ({statusTasks.length}) -
-
- {statusTasks.map((task, index) => { - const member = assignedMember(task); - const isNotRelevant = task.status === 'not_relevant'; - return ( -
handleTaskClick(task.id)} - > - {isNotRelevant && ( -
- - NOT RELEVANT - -
- )} -
e.stopPropagation()} - > - -
-
-
-
-
- {task.title} -
- -
- {task.description && ( -
- {task.description} -
- )} -
- {member && ( -
-
- {member.user?.image ? ( - {member.user.name - ) : ( - - {member.user?.name?.charAt(0) ?? '?'} - - )} -
-
- )} -
-
- ); - })} -
-
+ ); })} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskListItem.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskListItem.tsx new file mode 100644 index 000000000..bc0dc88ed --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernTaskListItem.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useMemo } from 'react'; + +import type { Member, Task, User } from '@db'; +import { Checkbox } from '@comp/ui/checkbox'; +import Image from 'next/image'; +import { AutomationIndicator } from './AutomationIndicator'; +import { TaskStatusSelector } from './TaskStatusSelector'; + +interface ModernTaskListItemProps { + task: Task & { + controls?: { id: string; name: string }[]; + evidenceAutomations?: Array<{ + id: string; + isEnabled: boolean; + name: string; + runs?: Array<{ + status: string; + success: boolean | null; + evaluationStatus: string | null; + createdAt: Date; + triggeredBy: string; + runDuration: number | null; + }>; + }>; + }; + members: (Member & { user: User })[]; + onClick: (taskId: string) => void; + selectable?: boolean; + selected?: boolean; + onSelectChange?: (taskId: string, checked: boolean) => void; +} + +export function ModernTaskListItem({ + task, + members, + onClick, + selectable = false, + selected = false, + onSelectChange, +}: ModernTaskListItemProps) { + const member = useMemo(() => { + if (!task.assigneeId) return null; + return members.find((m) => m.id === task.assigneeId) ?? null; + }, [members, task.assigneeId]); + const isNotRelevant = task.status === 'not_relevant'; + + const containerClasses = [ + 'group relative flex items-center gap-4 p-4 transition-colors cursor-pointer', + isNotRelevant + ? 'opacity-50 bg-slate-100/50 backdrop-blur-md hover:bg-slate-100/60' + : 'hover:bg-slate-50/50', + selected ? 'bg-primary/5 ring-1 ring-primary/30' : '', + ].join(' '); + + return ( +
{ + if (selectable) { + onSelectChange?.(task.id, !selected); + } else { + onClick(task.id); + } + }} + > + {isNotRelevant && ( +
+ + NOT RELEVANT + +
+ )} + {selectable ? ( +
{ + e.stopPropagation(); + }} + > + { + onSelectChange?.(task.id, Boolean(checked)); + }} + aria-label={`Select task ${task.title}`} + /> +
+ ) : null} +
e.stopPropagation()} + > + +
+
+
+
+
+ {task.title} +
+ +
+ {task.description && ( +
+ {task.description} +
+ )} +
+ {member && ( +
+
+ {member.user?.image ? ( + {member.user.name + ) : ( + + {member.user?.name?.charAt(0) ?? '?'} + + )} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskBulkActions.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskBulkActions.tsx new file mode 100644 index 000000000..37e976db7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskBulkActions.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@comp/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@comp/ui'; +import { ArrowUpDown, ChevronDown, Pencil, X } from 'lucide-react'; +import { BulkTaskStatusChangeModal } from './BulkTaskStatusChangeModal'; + +interface TaskBulkActionsProps { + selectedTaskIds: string[]; + onEdit: (isEditing: boolean) => void; + onClearSelection: () => void; +} + +export function TaskBulkActions({ selectedTaskIds, onEdit, onClearSelection }: TaskBulkActionsProps) { + const [isEditing, setIsEditing] = useState(false); + const [openBulk, setOpenBulk] = useState(false); + + useEffect(() => { + onEdit(isEditing); + }, [isEditing, onEdit]); + + return ( +
+ {isEditing ? ( + <> + {`${selectedTaskIds.length} item${selectedTaskIds.length > 1 ? 's' : ''} selected`} + + + + + + setOpenBulk(true)} disabled={selectedTaskIds.length === 0}> + + Change Status + + + + + + ) : ( + + )} + { + onClearSelection(); + setIsEditing(false); + }} + /> +
+ ); +} + diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 818fd5b30..10445dcff 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -6075,6 +6075,96 @@ ] } }, + "/v1/tasks/bulk": { + "patch": { + "description": "Bulk update the status of multiple tasks", + "operationId": "TasksController_updateTasksStatus_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "taskIds": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "tsk_abc123", + "tsk_def456" + ] + }, + "status": { + "type": "string", + "enum": [ + "todo", + "in_progress", + "done", + "not_relevant", + "failed" + ], + "example": "in_progress" + }, + "reviewDate": { + "type": "string", + "format": "date-time", + "example": "2025-01-01T00:00:00.000Z", + "description": "Optional review date to set on all tasks" + } + }, + "required": [ + "taskIds", + "status" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Tasks updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "updatedCount": { + "type": "number", + "example": 2 + } + } + } + } + } + }, + "400": { + "description": "Invalid request body" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Update status for multiple tasks", + "tags": [ + "Tasks" + ] + } + }, "/v1/tasks/{taskId}/attachments": { "get": { "description": "Retrieve all attachments for a specific task",