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
79 changes: 79 additions & 0 deletions apps/api/src/tasks/tasks.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ import {
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiExtraModels,
ApiBody,
ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
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';
Expand Down Expand Up @@ -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')
Expand Down
40 changes: 39 additions & 1 deletion apps/api/src/tasks/tasks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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');
}
}
}
21 changes: 20 additions & 1 deletion apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof vendorRiskAssessmentPayloadSchema>;

function parseVersionNumber(version: string | null | undefined): number {
if (!version || !version.startsWith('v')) return 0;
const n = Number.parseInt(version.slice(1), 10);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TaskStatus>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bulk Status Update</DialogTitle>
<DialogDescription>
{`${selectedCount} item${isSingular ? '' : 's'} ${
isSingular ? 'is' : 'are'
} selected. Are you sure you want to change the status?`}
</DialogDescription>
</DialogHeader>

<div className="space-y-2 flex flex-row items-center gap-4">
<Label htmlFor="task-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as TaskStatus)}>
<SelectTrigger id="task-status">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option} value={option}>
<div className="flex items-center gap-2">
<TaskStatusIndicator status={option} />
<span className="capitalize">{option.replace('_', ' ')}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>

<DialogFooter className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleMove} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Change Status'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading