Skip to content

Commit ebd6dc2

Browse files
HarshHarsh
authored andcommitted
added tool to create a task when a risk is created
1 parent 0ef1fb6 commit ebd6dc2

File tree

8 files changed

+444
-330
lines changed

8 files changed

+444
-330
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"Bash(git add:*)",
3030
"Bash(git commit:*)",
3131
"Bash(git push:*)",
32-
"Bash(npx vitest run:*)"
32+
"Bash(npx vitest run:*)",
33+
"Bash(xargs -I{} basename {})"
3334
]
3435
}
3536
}

Servers/advisor/aiActions/createRisk/execute.ts

Lines changed: 196 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,58 +10,129 @@
1010
* UI-driven controller path and the AI-driven executor path stay behind
1111
* the same validated insert + change-history pipeline.
1212
*
13-
* Field mapping notes:
14-
* - `approver` (LLM input) → `risk_approval` (DB column). The LLM
15-
* resolves a person via `list_users` first, then passes the numeric id.
16-
* - `risk_owner` (LLM input, optional) → falls back to `ctx.requesterId`
17-
* so the chatting user owns the risk by default if they didn't
18-
* explicitly assign someone else.
19-
* - `deadline` and `date_of_assessment` arrive as ISO date strings and
20-
* are converted to `Date` here so they hit Sequelize as a real date.
21-
* - `framework_ids` is forwarded as `frameworks` (the service expects
22-
* that key on the join-payload).
13+
* After the risk is created, three side effects run:
14+
* 1. A review task is auto-created and assigned to the risk owner.
15+
* 2. The risk owner is notified via in-app + email.
16+
* 3. Each linked project's owner is notified (if different from the
17+
* risk owner).
2318
*
24-
* Slice-1 gap: post-commit side effects (logEvent, portfolio snapshot,
25-
* risk-owner notification) are NOT run here. The HTTP controller path
26-
* still runs them for UI-created risks; the AI path will get them in a
27-
* later slice when we extend the post-approval pipeline.
19+
* All side effects run inside the same transaction. The notification
20+
* DB rows commit atomically with the risk; the Redis publish (for
21+
* real-time delivery) is external but idempotent — a phantom notification
22+
* pointing to a rolled-back entity is harmless.
2823
*/
2924

25+
import { QueryTypes } from "sequelize";
26+
import { sequelize } from "../../../database/db";
3027
import { createRiskService } from "../../../services/risk.service";
28+
import { createNewTaskQuery } from "../../../utils/task.utils";
3129
import { calculateRiskLevel } from "../../../utils/validations/riskValidation.utils";
30+
import { sendInAppNotification } from "../../../services/inAppNotification.service";
31+
import {
32+
NotificationType,
33+
NotificationEntityType,
34+
} from "../../../domain.layer/interfaces/i.notification";
35+
import { TaskPriority } from "../../../domain.layer/enums/task-priority.enum";
36+
import { TaskStatus } from "../../../domain.layer/enums/task-status.enum";
37+
import logger from "../../../utils/logger/fileLogger";
3238
import type {
3339
AiActionExecuteContext,
3440
AiActionExecuteResult,
3541
} from "../types";
3642
import type { AgentCreateRiskInput } from "./schema";
3743

38-
/**
39-
* The UI's AddNewRiskForm leaves severity/likelihood as defaulted Tab 1
40-
* fields (id 1 = Negligible/Rare) when the user doesn't touch them, then
41-
* always sends them along with a computed `risk_level_autocalculated`.
42-
* The AI tool keeps severity/likelihood optional so the LLM doesn't have
43-
* to invent a number when the user gave no hint — but to match the table
44-
* rendering you get from a UI-created risk we mirror the same defaults
45-
* here, then compute the level via the shared validation util.
46-
*/
4744
const DEFAULT_SEVERITY = "Negligible" as const;
4845
const DEFAULT_LIKELIHOOD = "Rare" as const;
4946

47+
/**
48+
* Map risk severity to task priority. Higher severity = higher priority
49+
* on the review task so it surfaces at the top of the task list.
50+
*/
51+
function severityToPriority(severity: string): TaskPriority {
52+
switch (severity) {
53+
case "Catastrophic":
54+
case "Major":
55+
return TaskPriority.HIGH;
56+
case "Moderate":
57+
return TaskPriority.MEDIUM;
58+
case "Minor":
59+
case "Negligible":
60+
default:
61+
return TaskPriority.LOW;
62+
}
63+
}
64+
65+
/**
66+
* Map risk severity to review deadline (days from now). Higher severity =
67+
* shorter deadline so critical risks get reviewed first.
68+
*/
69+
function severityToDeadlineDays(severity: string): number {
70+
switch (severity) {
71+
case "Catastrophic":
72+
return 2;
73+
case "Major":
74+
return 7;
75+
case "Moderate":
76+
return 14;
77+
case "Minor":
78+
return 30;
79+
case "Negligible":
80+
default:
81+
return 60;
82+
}
83+
}
84+
85+
/**
86+
* Get a user's display name from the DB. Returns "AI Advisor" if the
87+
* user isn't found (shouldn't happen, but defensive).
88+
*/
89+
async function getUserDisplayName(userId: number): Promise<string> {
90+
const rows = (await sequelize.query(
91+
`SELECT name, surname FROM users WHERE id = :userId`,
92+
{ replacements: { userId }, type: QueryTypes.SELECT },
93+
)) as Array<{ name: string; surname: string }>;
94+
const user = rows[0];
95+
return user ? `${user.name} ${user.surname}`.trim() : "AI Advisor";
96+
}
97+
98+
/**
99+
* Get the owner user id for each given project id. Returns a deduplicated
100+
* set of owner ids (excluding nulls).
101+
*/
102+
async function getProjectOwnerIds(
103+
projectIds: number[],
104+
organizationId: number,
105+
): Promise<number[]> {
106+
if (projectIds.length === 0) return [];
107+
108+
const rows = (await sequelize.query(
109+
`SELECT DISTINCT owner FROM projects
110+
WHERE id IN (:projectIds) AND organization_id = :organizationId AND owner IS NOT NULL`,
111+
{
112+
replacements: { projectIds, organizationId },
113+
type: QueryTypes.SELECT,
114+
},
115+
)) as Array<{ owner: number }>;
116+
117+
return rows.map((r) => r.owner);
118+
}
119+
50120
export async function executeCreateRisk(
51121
ctx: AiActionExecuteContext<AgentCreateRiskInput>,
52122
): Promise<AiActionExecuteResult> {
53123
const input = ctx.inputParams;
54124

55125
const severity = input.severity ?? DEFAULT_SEVERITY;
56126
const likelihood = input.likelihood ?? DEFAULT_LIKELIHOOD;
57-
// Same formula the frontend `RiskCalculator` uses, just runs on the
58-
// server. Without this, the "Risk Level" column on the risks table is
59-
// always blank for AI-created risks.
60127
const riskLevelAutocalculated = calculateRiskLevel(severity, likelihood);
128+
const riskOwner = input.risk_owner ?? ctx.requesterId;
129+
130+
// ═══════════════════════════════════════════════════════
131+
// 1. Create the risk
132+
// ═══════════════════════════════════════════════════════
61133

62134
const newRisk = await createRiskService(
63135
{
64-
// Tab 1
65136
risk_name: input.risk_name,
66137
risk_description: input.risk_description,
67138
ai_lifecycle_phase: input.ai_lifecycle_phase,
@@ -77,11 +148,9 @@ export async function executeCreateRisk(
77148
| "High risk"
78149
| "Very high risk",
79150
review_notes: input.review_notes,
80-
risk_owner: input.risk_owner ?? ctx.requesterId,
151+
risk_owner: riskOwner,
81152
projects: input.project_ids ?? [],
82153
frameworks: input.framework_ids ?? [],
83-
84-
// Tab 2 — Mitigation
85154
mitigation_status: input.mitigation_status,
86155
mitigation_plan: input.mitigation_plan,
87156
current_risk_level: input.current_risk_level,
@@ -104,5 +173,102 @@ export async function executeCreateRisk(
104173
);
105174
}
106175

176+
// ═══════════════════════════════════════════════════════
177+
// 2. Auto-create a review task for the risk owner
178+
// ═══════════════════════════════════════════════════════
179+
180+
const deadlineDays = severityToDeadlineDays(severity);
181+
const dueDate = new Date();
182+
dueDate.setDate(dueDate.getDate() + deadlineDays);
183+
184+
try {
185+
await createNewTaskQuery(
186+
{
187+
title: `Review: ${input.risk_name}`,
188+
description:
189+
`Auto-created by AI Advisor. A new ${severity} risk "${input.risk_name}" requires review. ` +
190+
`Please validate the risk assessment, verify the mitigation plan, and confirm the risk level.`,
191+
creator_id: ctx.requesterId,
192+
due_date: dueDate,
193+
priority: severityToPriority(severity),
194+
status: TaskStatus.OPEN,
195+
categories: ["risk-review", "ai-created"],
196+
},
197+
ctx.organizationId,
198+
ctx.transaction,
199+
[{ user_id: riskOwner }],
200+
);
201+
} catch (taskError) {
202+
// Task creation is a side effect — log but don't fail the risk creation.
203+
logger.error(
204+
`[agent_create_risk] failed to auto-create review task for risk #${newRisk.id}:`,
205+
taskError,
206+
);
207+
}
208+
209+
// ═══════════════════════════════════════════════════════
210+
// 3. Notify the risk owner
211+
// ═══════════════════════════════════════════════════════
212+
213+
try {
214+
const requesterName = await getUserDisplayName(ctx.requesterId);
215+
216+
await sendInAppNotification(ctx.organizationId, {
217+
user_id: riskOwner,
218+
type: NotificationType.ASSIGNMENT_OWNER,
219+
title: "New risk assigned to you",
220+
message:
221+
`${requesterName} created a ${severity} risk "${input.risk_name}" via AI Advisor and assigned you as the owner. ` +
222+
`Review deadline: ${dueDate.toISOString().slice(0, 10)}.`,
223+
entity_type: NotificationEntityType.RISK,
224+
entity_id: newRisk.id!,
225+
entity_name: input.risk_name,
226+
action_url: `/risk-management?riskId=${newRisk.id}`,
227+
});
228+
} catch (notifyError) {
229+
logger.error(
230+
`[agent_create_risk] failed to notify risk owner for risk #${newRisk.id}:`,
231+
notifyError,
232+
);
233+
}
234+
235+
// ═══════════════════════════════════════════════════════
236+
// 4. Notify project owner(s) — if the risk is linked to projects
237+
// and the project owner is not the same as the risk owner
238+
// ═══════════════════════════════════════════════════════
239+
240+
try {
241+
const projectIds = input.project_ids ?? [];
242+
if (projectIds.length > 0) {
243+
const projectOwnerIds = await getProjectOwnerIds(
244+
projectIds,
245+
ctx.organizationId,
246+
);
247+
248+
for (const ownerId of projectOwnerIds) {
249+
// Skip if the project owner IS the risk owner — they already got notified above.
250+
if (ownerId === riskOwner) continue;
251+
252+
await sendInAppNotification(ctx.organizationId, {
253+
user_id: ownerId,
254+
type: NotificationType.SYSTEM,
255+
title: "New risk added to your project",
256+
message:
257+
`A ${severity} risk "${input.risk_name}" was created via AI Advisor and linked to your project. ` +
258+
`Risk owner has been assigned and a review task has been created.`,
259+
entity_type: NotificationEntityType.RISK,
260+
entity_id: newRisk.id!,
261+
entity_name: input.risk_name,
262+
action_url: `/risk-management?riskId=${newRisk.id}`,
263+
});
264+
}
265+
}
266+
} catch (notifyError) {
267+
logger.error(
268+
`[agent_create_risk] failed to notify project owners for risk #${newRisk.id}:`,
269+
notifyError,
270+
);
271+
}
272+
107273
return { entityId: newRisk.id };
108274
}

Servers/advisor/aiActions/createRisk/file.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export async function fileCreateRisk(
110110
status: "pending_approval",
111111
approvalRequestId: approvalRequest.id!,
112112
preview,
113-
message: `Created approval request #${approvalRequest.id}. Tell the user to open Pending Approvals and approve or reject "${preview}".`,
113+
message: `Created approval request #${approvalRequest.id}. Tell the user to open Pending Approvals to approve or reject "${preview}". Also let them know: once approved, a review task will be auto-created and assigned to the risk owner with a deadline based on the risk severity, and the risk owner (and any linked project owners) will be notified.`,
114114
};
115115
} catch (error) {
116116
await transaction.rollback();
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Read-only `list_frameworks` lookup function for the AI advisor.
3+
*
4+
* Pairs with `Servers/advisor/tools/frameworkLookupTools.ts`.
5+
*
6+
* Frameworks are global reference data (no organization_id) — EU AI Act,
7+
* ISO 42001, ISO 27001, NIST AI RMF, etc. They're shared across all
8+
* tenants. The query is a simple SELECT on the `frameworks` table with
9+
* an optional ILIKE filter, no org scoping needed.
10+
*
11+
* NOTE: This file is separate from `frameworkFunctions.ts` (which holds
12+
* the analytics tool executors) to keep lookup tools grouped with their
13+
* family (`list_users`, `list_projects`, `list_frameworks`).
14+
*/
15+
16+
import { sequelize } from "../../database/db";
17+
import { QueryTypes } from "sequelize";
18+
import logger from "../../utils/logger/fileLogger";
19+
20+
export interface ListFrameworksParams {
21+
search?: string;
22+
}
23+
24+
export interface AdvisorFrameworkSummary {
25+
id: number;
26+
name: string;
27+
description: string | null;
28+
/** true = organizational (single shared project), false = project-based (linked per project) */
29+
is_organizational: boolean;
30+
}
31+
32+
const listFrameworks = async (
33+
params: ListFrameworksParams,
34+
_organizationId: number,
35+
): Promise<AdvisorFrameworkSummary[]> => {
36+
const replacements: Record<string, unknown> = {};
37+
38+
// Only return frameworks that are actually enabled (adopted) in this
39+
// organization. A framework is "enabled" when at least one project in
40+
// the org has it linked via `projects_frameworks`. Without this filter,
41+
// the LLM could propose linking a risk to a framework nobody has
42+
// adopted — the DB row would exist but nothing in the UI would show it.
43+
replacements.organization_id = _organizationId;
44+
45+
let searchClause = "";
46+
if (params.search && params.search.trim().length > 0) {
47+
replacements.search = `%${params.search.trim()}%`;
48+
searchClause = "AND frameworks.name ILIKE :search";
49+
}
50+
51+
try {
52+
const rows = (await sequelize.query(
53+
`
54+
SELECT DISTINCT
55+
frameworks.id,
56+
frameworks.name,
57+
frameworks.description,
58+
frameworks.is_organizational
59+
FROM frameworks
60+
INNER JOIN projects_frameworks pf
61+
ON pf.framework_id = frameworks.id
62+
AND pf.organization_id = :organization_id
63+
WHERE 1=1 ${searchClause}
64+
ORDER BY frameworks.name ASC, frameworks.id ASC
65+
`,
66+
{
67+
replacements,
68+
type: QueryTypes.SELECT,
69+
},
70+
)) as Array<{
71+
id: number;
72+
name: string;
73+
description: string | null;
74+
is_organizational: boolean;
75+
}>;
76+
77+
return rows.map((r) => ({
78+
id: r.id,
79+
name: r.name,
80+
description: r.description,
81+
is_organizational: r.is_organizational,
82+
}));
83+
} catch (error) {
84+
logger.error("Error listing frameworks for advisor:", error);
85+
throw new Error(
86+
`Failed to list frameworks: ${error instanceof Error ? error.message : "unknown error"}`,
87+
);
88+
}
89+
};
90+
91+
const availableFrameworkLookupTools: any = {
92+
list_frameworks: listFrameworks,
93+
};
94+
95+
export { availableFrameworkLookupTools };

0 commit comments

Comments
 (0)