Skip to content

Commit 2876232

Browse files
Marfuenclaude
andauthored
feat(tasks): add justification when marking evidence tasks as not relevant (#2798)
Adds a required justification flow when marking evidence tasks as "not relevant" so auditors can review why a task was excluded. Shows a confirmation dialog with a textarea, stores the reason on the task, and displays a banner on the task detail page. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e47dc0c commit 2876232

13 files changed

Lines changed: 287 additions & 10 deletions

File tree

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,12 @@ export class TasksController {
265265
example: '2025-01-01T00:00:00.000Z',
266266
description: 'Optional review date to set on all tasks',
267267
},
268+
notRelevantJustification: {
269+
type: 'string',
270+
example: 'This control is out of scope for our SOC 2 audit.',
271+
description:
272+
'Required justification when marking evidence tasks as not_relevant',
273+
},
268274
},
269275
required: ['taskIds', 'status'],
270276
},
@@ -291,9 +297,10 @@ export class TasksController {
291297
taskIds: string[];
292298
status: TaskStatus;
293299
reviewDate?: string;
300+
notRelevantJustification?: string;
294301
},
295302
): Promise<{ updatedCount: number }> {
296-
const { taskIds, status, reviewDate } = body;
303+
const { taskIds, status, reviewDate, notRelevantJustification } = body;
297304

298305
if (!Array.isArray(taskIds) || taskIds.length === 0) {
299306
throw new BadRequestException('taskIds must be a non-empty array');
@@ -326,6 +333,7 @@ export class TasksController {
326333
status,
327334
parsedReviewDate,
328335
userId,
336+
notRelevantJustification,
329337
);
330338
}
331339

@@ -811,6 +819,12 @@ export class TasksController {
811819
format: 'date-time',
812820
example: '2025-01-01T00:00:00.000Z',
813821
},
822+
notRelevantJustification: {
823+
type: 'string',
824+
example: 'This control is out of scope for our SOC 2 audit.',
825+
description:
826+
'Required justification when marking evidence tasks as not_relevant',
827+
},
814828
},
815829
},
816830
})
@@ -846,6 +860,7 @@ export class TasksController {
846860
integrationScheduleFrequency?: string;
847861
department?: string;
848862
reviewDate?: string;
863+
notRelevantJustification?: string;
849864
},
850865
): Promise<TaskResponseDto> {
851866
const userId = await this.resolveTaskMutationUserId(
@@ -884,6 +899,7 @@ export class TasksController {
884899
| undefined,
885900
department: body.department,
886901
reviewDate: parsedReviewDate,
902+
notRelevantJustification: body.notRelevantJustification,
887903
},
888904
userId,
889905
);

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ export class TasksService {
372372
status: TaskStatus,
373373
reviewDate: Date | undefined,
374374
changedByUserId: string,
375+
notRelevantJustification?: string,
375376
): Promise<{ updatedCount: number }> {
376377
try {
377378
// Enforce approval workflow: exclude tasks that can't be bulk-updated
@@ -387,11 +388,17 @@ export class TasksService {
387388
where.approverId = null;
388389
}
389390

391+
const justificationData =
392+
status === TaskStatus.not_relevant
393+
? { notRelevantJustification: notRelevantJustification ?? null }
394+
: { notRelevantJustification: null };
395+
390396
const result = await db.task.updateMany({
391397
where,
392398
data: {
393399
status,
394400
updatedAt: new Date(),
401+
...justificationData,
395402
...(reviewDate !== undefined ? { reviewDate } : {}),
396403
},
397404
});
@@ -561,6 +568,7 @@ export class TasksService {
561568
integrationScheduleFrequency?: TaskFrequency;
562569
department?: string;
563570
reviewDate?: Date | null;
571+
notRelevantJustification?: string;
564572
},
565573
changedByUserId: string,
566574
): Promise<TaskResponseDto> {
@@ -596,6 +604,7 @@ export class TasksService {
596604
integrationScheduleFrequency?: TaskFrequency;
597605
department?: string;
598606
reviewDate?: Date | null;
607+
notRelevantJustification?: string | null;
599608
} = {};
600609

601610
if (updateData.title !== undefined) {
@@ -626,6 +635,13 @@ export class TasksService {
626635
);
627636
}
628637
dataToUpdate.status = updateData.status;
638+
639+
if (updateData.status === TaskStatus.not_relevant) {
640+
dataToUpdate.notRelevantJustification =
641+
updateData.notRelevantJustification ?? null;
642+
} else {
643+
dataToUpdate.notRelevantJustification = null;
644+
}
629645
}
630646
if (updateData.assigneeId !== undefined) {
631647
if (updateData.assigneeId !== null) {
@@ -712,6 +728,11 @@ export class TasksService {
712728
field: 'status',
713729
oldValue: existingTask.status,
714730
newValue: updateData.status,
731+
...(updateData.status === TaskStatus.not_relevant &&
732+
updateData.notRelevantJustification && {
733+
notRelevantJustification:
734+
updateData.notRelevantJustification,
735+
}),
715736
},
716737
},
717738
});

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
TabsTrigger,
4040
Text,
4141
} from '@trycompai/design-system';
42+
import { SubtractAlt } from '@trycompai/design-system/icons';
4243
import { CheckCircle2, Clock, Download, RefreshCw, SendHorizontal, Trash2, XCircle } from 'lucide-react';
4344
import Link from 'next/link';
4445
import { useParams, useSearchParams } from 'next/navigation';
@@ -177,7 +178,9 @@ export function SingleTask({
177178
};
178179

179180
const handleUpdateTask = async (
180-
updates: Partial<Pick<Task, 'status' | 'assigneeId' | 'approverId' | 'frequency' | 'department' | 'reviewDate'>>,
181+
updates: Partial<Pick<Task, 'status' | 'assigneeId' | 'approverId' | 'frequency' | 'department' | 'reviewDate'>> & {
182+
notRelevantJustification?: string;
183+
},
181184
) => {
182185
try {
183186
await updateTask({
@@ -187,6 +190,7 @@ export function SingleTask({
187190
frequency: updates.frequency,
188191
department: updates.department,
189192
reviewDate: updates.reviewDate ? String(updates.reviewDate) : undefined,
193+
notRelevantJustification: updates.notRelevantJustification,
190194
});
191195
toast.success('Task updated');
192196
mutateActivity();
@@ -318,6 +322,11 @@ export function SingleTask({
318322
)}
319323
</Stack>
320324

325+
{/* Not Relevant Banner */}
326+
{task.status === 'not_relevant' && task.notRelevantJustification && (
327+
<NotRelevantBanner justification={task.notRelevantJustification} />
328+
)}
329+
321330
{/* Approval Banner */}
322331
{evidenceApprovalEnabled && isInReview && (
323332
<ApprovalBanner
@@ -539,6 +548,20 @@ function TaskActivitySection({ taskId }: { taskId: string }) {
539548
return <RecentAuditLogs logs={logs} />;
540549
}
541550

551+
function NotRelevantBanner({ justification }: { justification: string }) {
552+
return (
553+
<div className="rounded-lg border border-l-4 border-border border-l-muted-foreground/50 bg-muted/30 p-4">
554+
<HStack gap="sm" align="start">
555+
<SubtractAlt size={20} className="text-muted-foreground mt-0.5 shrink-0" />
556+
<Stack gap="xs">
557+
<Text size="sm" weight="medium">Marked as Not Relevant</Text>
558+
<Text size="sm" variant="muted">{justification}</Text>
559+
</Stack>
560+
</HStack>
561+
</div>
562+
);
563+
}
564+
542565
function ApprovalBanner({
543566
canApprove,
544567
canCancel,

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ import {
2222
Text,
2323
} from '@trycompai/design-system';
2424
import { Calendar } from 'lucide-react';
25+
import { useState } from 'react';
26+
import { NotRelevantJustificationDialog } from '../../components/NotRelevantJustificationDialog';
2527
import { useTask } from '../hooks/use-task';
2628
import { taskStatuses, taskFrequencies, taskDepartments } from './constants';
2729

2830
interface TaskPropertiesSidebarProps {
2931
handleUpdateTask: (
30-
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'approverId' | 'frequency' | 'department' | 'reviewDate'>>,
32+
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'approverId' | 'frequency' | 'department' | 'reviewDate'>> & {
33+
notRelevantJustification?: string;
34+
},
3135
) => void;
3236
evidenceApprovalEnabled?: boolean;
3337
onRequestApproval?: () => void;
@@ -43,6 +47,7 @@ export function TaskPropertiesSidebar({
4347
const { members } = useOrganizationMembers();
4448
const { hasPermission } = usePermissions();
4549
const canUpdate = hasPermission('task', 'update');
50+
const [justificationDialogOpen, setJustificationDialogOpen] = useState(false);
4651

4752
if (isLoading || !task) return null;
4853

@@ -55,10 +60,23 @@ export function TaskPropertiesSidebar({
5560
onRequestApproval();
5661
return;
5762
}
63+
if (selectedStatus === 'not_relevant') {
64+
setJustificationDialogOpen(true);
65+
return;
66+
}
5867
handleUpdateTask({ status: selectedStatus as TaskStatus });
5968
};
6069

70+
const handleNotRelevantConfirm = (justification: string) => {
71+
handleUpdateTask({
72+
status: 'not_relevant' as TaskStatus,
73+
notRelevantJustification: justification,
74+
});
75+
setJustificationDialogOpen(false);
76+
};
77+
6178
return (
79+
<>
6280
<Section title="Evidence Settings">
6381
<Stack gap="md">
6482
<Grid cols={{ base: '1', md: '2' }} gap="4">
@@ -170,5 +188,12 @@ export function TaskPropertiesSidebar({
170188
</Grid>
171189
</Stack>
172190
</Section>
191+
192+
<NotRelevantJustificationDialog
193+
open={justificationDialogOpen}
194+
onOpenChange={setJustificationDialogOpen}
195+
onConfirm={handleNotRelevantConfirm}
196+
/>
197+
</>
173198
);
174199
}

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface UpdateTaskPayload {
1818
title?: string;
1919
description?: string;
2020
integrationScheduleFrequency?: TaskFrequency;
21+
notRelevantJustification?: string;
2122
}
2223

2324
interface UseTaskReturn {

apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { SelectAssignee } from '@/components/SelectAssignee';
4+
import { Label, Textarea } from '@trycompai/design-system';
45
import { Button } from '@trycompai/ui/button';
56
import {
67
Dialog,
@@ -10,7 +11,6 @@ import {
1011
DialogHeader,
1112
DialogTitle,
1213
} from '@trycompai/ui/dialog';
13-
import { Label } from '@trycompai/ui/label';
1414
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@trycompai/ui/select';
1515
import { Member, TaskStatus, User } from '@db';
1616
import { Loader2 } from 'lucide-react';
@@ -48,16 +48,19 @@ export function BulkTaskStatusChangeModal({
4848
const [status, setStatus] = useState<TaskStatus>(defaultStatus);
4949
const [isSubmitting, setIsSubmitting] = useState(false);
5050
const [approverId, setApproverId] = useState<string | null>(null);
51+
const [justification, setJustification] = useState('');
5152
const selectedCount = selectedTaskIds.length;
5253
const isSingular = selectedCount === 1;
5354

5455
// Whether we need approval for this status change
5556
const needsApproval = evidenceApprovalEnabled && status === TaskStatus.done;
57+
const isNotRelevant = status === TaskStatus.not_relevant;
5658

5759
useEffect(() => {
5860
if (open) {
5961
setStatus(defaultStatus);
6062
setApproverId(null);
63+
setJustification('');
6164
}
6265
}, [defaultStatus, open]);
6366

@@ -82,7 +85,12 @@ export function BulkTaskStatusChangeModal({
8285
} else {
8386
// Normal bulk status change using hook
8487
const reviewDate = status === TaskStatus.done ? new Date().toISOString() : undefined;
85-
const { updatedCount } = await bulkUpdateStatus(selectedTaskIds, status, reviewDate);
88+
const { updatedCount } = await bulkUpdateStatus(
89+
selectedTaskIds,
90+
status,
91+
reviewDate,
92+
isNotRelevant ? justification.trim() || undefined : undefined,
93+
);
8694
toast.success(`Updated ${updatedCount} task${updatedCount === 1 ? '' : 's'}`);
8795
}
8896

@@ -142,6 +150,22 @@ export function BulkTaskStatusChangeModal({
142150
/>
143151
</div>
144152
)}
153+
154+
{isNotRelevant && (
155+
<div className="space-y-2">
156+
<Label htmlFor="bulk-justification">Justification</Label>
157+
<Textarea
158+
id="bulk-justification"
159+
placeholder="e.g. This control is out of scope for our current compliance program..."
160+
value={justification}
161+
onChange={(e) => setJustification(e.target.value)}
162+
rows={3}
163+
/>
164+
<p className="text-xs text-muted-foreground">
165+
Auditors may review this justification during an audit.
166+
</p>
167+
</div>
168+
)}
145169
</div>
146170

147171
<DialogFooter className="flex justify-end gap-2">

apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ const mockTasks = [
143143
approvalComment: null,
144144
organizationId: 'org_1',
145145
embeddingHash: null,
146+
notRelevantJustification: null,
146147
controls: [],
147148
},
148149
{
@@ -170,6 +171,7 @@ const mockTasks = [
170171
approvalComment: null,
171172
organizationId: 'org_1',
172173
embeddingHash: null,
174+
notRelevantJustification: null,
173175
controls: [],
174176
},
175177
];

0 commit comments

Comments
 (0)