Skip to content

Commit 3fd1542

Browse files
committed
chore: merge main into release for new releases
2 parents eb3cfaa + 83595bb commit 3fd1542

10 files changed

Lines changed: 717 additions & 107 deletions

File tree

‎apps/api/src/tasks/tasks.controller.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@ import {
66
Delete,
77
Get,
88
Param,
9+
Patch,
910
Post,
1011
UseGuards,
1112
} from '@nestjs/common';
1213
import {
1314
ApiExtraModels,
15+
ApiBody,
1416
ApiHeader,
1517
ApiOperation,
1618
ApiParam,
1719
ApiResponse,
1820
ApiSecurity,
1921
ApiTags,
2022
} from '@nestjs/swagger';
23+
import { TaskStatus } from '@db';
2124
import { AttachmentsService } from '../attachments/attachments.service';
2225
import { UploadAttachmentDto } from '../attachments/upload-attachment.dto';
2326
import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
@@ -144,6 +147,82 @@ export class TasksController {
144147
return await this.tasksService.getTask(organizationId, taskId);
145148
}
146149

150+
@Patch('bulk')
151+
@ApiOperation({
152+
summary: 'Update status for multiple tasks',
153+
description: 'Bulk update the status of multiple tasks',
154+
})
155+
@ApiBody({
156+
schema: {
157+
type: 'object',
158+
properties: {
159+
taskIds: {
160+
type: 'array',
161+
items: { type: 'string' },
162+
example: ['tsk_abc123', 'tsk_def456'],
163+
},
164+
status: {
165+
type: 'string',
166+
enum: Object.values(TaskStatus),
167+
example: TaskStatus.in_progress,
168+
},
169+
reviewDate: {
170+
type: 'string',
171+
format: 'date-time',
172+
example: '2025-01-01T00:00:00.000Z',
173+
description: 'Optional review date to set on all tasks',
174+
},
175+
},
176+
required: ['taskIds', 'status'],
177+
},
178+
})
179+
@ApiResponse({
180+
status: 200,
181+
description: 'Tasks updated successfully',
182+
schema: {
183+
type: 'object',
184+
properties: {
185+
updatedCount: { type: 'number', example: 2 },
186+
},
187+
},
188+
})
189+
@ApiResponse({
190+
status: 400,
191+
description: 'Invalid request body',
192+
})
193+
async updateTasksStatus(
194+
@OrganizationId() organizationId: string,
195+
@Body()
196+
body: {
197+
taskIds: string[];
198+
status: TaskStatus;
199+
reviewDate?: string;
200+
},
201+
): Promise<{ updatedCount: number }> {
202+
const { taskIds, status, reviewDate } = body;
203+
204+
if (!Array.isArray(taskIds) || taskIds.length === 0) {
205+
throw new BadRequestException('taskIds must be a non-empty array');
206+
}
207+
208+
if (!Object.values(TaskStatus).includes(status)) {
209+
throw new BadRequestException('status is invalid');
210+
}
211+
212+
let parsedReviewDate: Date | undefined;
213+
if (reviewDate !== undefined) {
214+
if (reviewDate === null || typeof reviewDate !== 'string') {
215+
throw new BadRequestException('reviewDate is invalid');
216+
}
217+
parsedReviewDate = new Date(reviewDate);
218+
if (Number.isNaN(parsedReviewDate.getTime())) {
219+
throw new BadRequestException('reviewDate is invalid');
220+
}
221+
}
222+
223+
return await this.tasksService.updateTasksStatus(organizationId, taskIds, status, parsedReviewDate);
224+
}
225+
147226
// ==================== TASK ATTACHMENTS ====================
148227

149228
@Get(':taskId/attachments')

‎apps/api/src/tasks/tasks.service.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
Injectable,
44
InternalServerErrorException,
55
} from '@nestjs/common';
6-
import { db } from '@trycompai/db';
6+
import { db, TaskStatus } from '@trycompai/db';
77
import { TaskResponseDto } from './dto/task-responses.dto';
88

99
@Injectable()
@@ -113,4 +113,42 @@ export class TasksService {
113113

114114
return runs;
115115
}
116+
117+
/**
118+
* Update status for multiple tasks
119+
*/
120+
async updateTasksStatus(
121+
organizationId: string,
122+
taskIds: string[],
123+
status: TaskStatus,
124+
reviewDate?: Date,
125+
): Promise<{ updatedCount: number }> {
126+
try {
127+
const result = await db.task.updateMany({
128+
where: {
129+
id: {
130+
in: taskIds,
131+
},
132+
organizationId,
133+
},
134+
data: {
135+
status,
136+
updatedAt: new Date(),
137+
...(reviewDate !== undefined ? { reviewDate } : {}),
138+
},
139+
});
140+
141+
if (result.count === 0) {
142+
throw new BadRequestException('No tasks were updated. Check task IDs or organization access.');
143+
}
144+
145+
return { updatedCount: result.count };
146+
} catch (error) {
147+
console.error('Error updating task statuses:', error);
148+
if (error instanceof BadRequestException) {
149+
throw error;
150+
}
151+
throw new InternalServerErrorException('Failed to update task statuses');
152+
}
153+
}
116154
}

‎apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
type TaskItemEntityType,
77
} from '@db';
88
import type { Prisma } from '@prisma/client';
9+
import type { Task } from '@trigger.dev/sdk';
910
import { logger, queue, schemaTask } from '@trigger.dev/sdk';
11+
import type { z } from 'zod';
1012

1113
import { resolveTaskCreatorAndAssignee } from './vendor-risk-assessment/assignee';
1214
import { VENDOR_RISK_ASSESSMENT_TASK_ID } from './vendor-risk-assessment/constants';
@@ -20,6 +22,19 @@ import { vendorRiskAssessmentPayloadSchema } from './vendor-risk-assessment/sche
2022

2123
const VERIFY_RISK_ASSESSMENT_TASK_TITLE = 'Verify risk assessment' as const;
2224

25+
type VendorRiskAssessmentResult = {
26+
success: true;
27+
vendorId: string;
28+
deduped: boolean;
29+
researched: boolean;
30+
skipped?: boolean;
31+
reason?: 'no_website' | 'invalid_website';
32+
riskAssessmentVersion: string | null;
33+
verifyTaskItemId?: string;
34+
};
35+
36+
type VendorRiskAssessmentTaskInput = z.input<typeof vendorRiskAssessmentPayloadSchema>;
37+
2338
function parseVersionNumber(version: string | null | undefined): number {
2439
if (!version || !version.startsWith('v')) return 0;
2540
const n = Number.parseInt(version.slice(1), 10);
@@ -185,7 +200,11 @@ function normalizeWebsite(website: string): string | null {
185200
}
186201
}
187202

188-
export const vendorRiskAssessmentTask = schemaTask({
203+
export const vendorRiskAssessmentTask: Task<
204+
typeof VENDOR_RISK_ASSESSMENT_TASK_ID,
205+
VendorRiskAssessmentTaskInput,
206+
VendorRiskAssessmentResult
207+
> = schemaTask({
189208
id: VENDOR_RISK_ASSESSMENT_TASK_ID,
190209
queue: queue({ name: 'vendor-risk-assessment', concurrencyLimit: 10 }),
191210
schema: vendorRiskAssessmentPayloadSchema,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use client';
2+
3+
import { useEffect, useMemo, useState } from 'react';
4+
import { Button } from '@comp/ui/button';
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from '@comp/ui/dialog';
13+
import { Label } from '@comp/ui/label';
14+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
15+
import { TaskStatus } from '@db';
16+
import { Loader2 } from 'lucide-react';
17+
import { useParams, useRouter } from 'next/navigation';
18+
import { apiClient } from '@/lib/api-client';
19+
import { toast } from 'sonner';
20+
import { TaskStatusIndicator } from './TaskStatusIndicator';
21+
22+
interface BulkTaskStatusChangeModalProps {
23+
open: boolean;
24+
selectedTaskIds: string[];
25+
onOpenChange: (open: boolean) => void;
26+
onSuccess?: () => void;
27+
}
28+
29+
export function BulkTaskStatusChangeModal({
30+
open,
31+
onOpenChange,
32+
selectedTaskIds,
33+
onSuccess,
34+
}: BulkTaskStatusChangeModalProps) {
35+
const router = useRouter();
36+
const params = useParams<{ orgId: string }>();
37+
const orgIdParam = Array.isArray(params.orgId) ? params.orgId[0] : params.orgId;
38+
39+
const statusOptions = useMemo(() => Object.values(TaskStatus) as TaskStatus[], []);
40+
const defaultStatus = statusOptions[0];
41+
42+
const [status, setStatus] = useState<TaskStatus>(defaultStatus);
43+
const [isSubmitting, setIsSubmitting] = useState(false);
44+
const selectedCount = selectedTaskIds.length;
45+
const isSingular = selectedCount === 1;
46+
47+
useEffect(() => {
48+
if (open) {
49+
setStatus(defaultStatus);
50+
}
51+
}, [defaultStatus, open]);
52+
53+
const handleMove = async () => {
54+
if (!orgIdParam || selectedTaskIds.length === 0) {
55+
return;
56+
}
57+
58+
try {
59+
setIsSubmitting(true);
60+
const payload = {
61+
taskIds: selectedTaskIds,
62+
status,
63+
...(status === TaskStatus.done ? { reviewDate: new Date().toISOString() } : {}),
64+
};
65+
66+
const response = await apiClient.patch<{ updatedCount: number }>(
67+
'/v1/tasks/bulk',
68+
payload,
69+
orgIdParam,
70+
);
71+
72+
if (response.error) {
73+
throw new Error(response.error);
74+
}
75+
76+
const updatedCount = response.data?.updatedCount ?? selectedTaskIds.length;
77+
toast.success(`Updated ${updatedCount} task${updatedCount === 1 ? '' : 's'}`);
78+
onSuccess?.();
79+
onOpenChange(false);
80+
router.refresh();
81+
} catch (error) {
82+
console.error('Failed to bulk update task status', error);
83+
const message = error instanceof Error ? error.message : 'Failed to update tasks';
84+
toast.error(message);
85+
} finally {
86+
setIsSubmitting(false);
87+
}
88+
};
89+
90+
return (
91+
<Dialog open={open} onOpenChange={onOpenChange}>
92+
<DialogContent>
93+
<DialogHeader>
94+
<DialogTitle>Bulk Status Update</DialogTitle>
95+
<DialogDescription>
96+
{`${selectedCount} item${isSingular ? '' : 's'} ${
97+
isSingular ? 'is' : 'are'
98+
} selected. Are you sure you want to change the status?`}
99+
</DialogDescription>
100+
</DialogHeader>
101+
102+
<div className="space-y-2 flex flex-row items-center gap-4">
103+
<Label htmlFor="task-status">Status</Label>
104+
<Select value={status} onValueChange={(value) => setStatus(value as TaskStatus)}>
105+
<SelectTrigger id="task-status">
106+
<SelectValue placeholder="Select status" />
107+
</SelectTrigger>
108+
<SelectContent>
109+
{statusOptions.map((option) => (
110+
<SelectItem key={option} value={option}>
111+
<div className="flex items-center gap-2">
112+
<TaskStatusIndicator status={option} />
113+
<span className="capitalize">{option.replace('_', ' ')}</span>
114+
</div>
115+
</SelectItem>
116+
))}
117+
</SelectContent>
118+
</Select>
119+
</div>
120+
121+
<DialogFooter className="flex justify-end gap-2">
122+
<Button variant="outline" onClick={() => onOpenChange(false)}>
123+
Cancel
124+
</Button>
125+
<Button onClick={handleMove} disabled={isSubmitting}>
126+
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Change Status'}
127+
</Button>
128+
</DialogFooter>
129+
</DialogContent>
130+
</Dialog>
131+
);
132+
}

0 commit comments

Comments
 (0)