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" ;
3027import { createRiskService } from "../../../services/risk.service" ;
28+ import { createNewTaskQuery } from "../../../utils/task.utils" ;
3129import { 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" ;
3238import type {
3339 AiActionExecuteContext ,
3440 AiActionExecuteResult ,
3541} from "../types" ;
3642import 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- */
4744const DEFAULT_SEVERITY = "Negligible" as const ;
4845const 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+
50120export 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}
0 commit comments