Skip to content

Commit 4c3c43b

Browse files
HarshHarsh
authored andcommitted
create email notifications for user assignment
1 parent 334063a commit 4c3c43b

File tree

17 files changed

+1378
-21
lines changed

17 files changed

+1378
-21
lines changed

Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/ControlCategory.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
Typography,
1313
} from "@mui/material";
1414
import { ControlCategory as ControlCategoryModel } from "../../../../domain/types/ControlCategory";
15-
import { useState, useEffect } from "react";
15+
import { useState, useEffect, useRef } from "react";
1616
import { ChevronRight } from "lucide-react";
1717
import ControlsTable from "./ControlsTable";
1818

@@ -46,6 +46,8 @@ const ControlCategoryTile: React.FC<ControlCategoryProps> = ({
4646
dueDateFilter,
4747
initialControlCategoryId,
4848
}) => {
49+
const accordionRef = useRef<HTMLDivElement>(null);
50+
4951
// Auto-expand if this category matches the initialControlCategoryId
5052
const [expanded, setExpanded] = useState<number | false>(() => {
5153
if (initialControlCategoryId && controlCategory.id === initialControlCategoryId) {
@@ -55,10 +57,17 @@ const ControlCategoryTile: React.FC<ControlCategoryProps> = ({
5557
});
5658
const [filteredControlsCount, setFilteredControlsCount] = useState<number | null>(null);
5759

58-
// Update expanded state when initialControlCategoryId changes
60+
// Update expanded state and scroll into view when initialControlCategoryId changes
5961
useEffect(() => {
6062
if (initialControlCategoryId && controlCategory.id === initialControlCategoryId) {
6163
setExpanded(controlCategory.id);
64+
// Scroll into view after a short delay to allow accordion expansion animation
65+
setTimeout(() => {
66+
accordionRef.current?.scrollIntoView({
67+
behavior: "smooth",
68+
block: "start",
69+
});
70+
}, 100);
6271
}
6372
}, [initialControlCategoryId, controlCategory.id]);
6473

@@ -72,7 +81,7 @@ const ControlCategoryTile: React.FC<ControlCategoryProps> = ({
7281
: { bg: "#FFF8E1", color: "#795548" };
7382

7483
return (
75-
<Stack className="control-category">
84+
<Stack className="control-category" ref={accordionRef}>
7685
<Accordion
7786
className="control-category-accordion"
7887
expanded={expanded === controlCategory.id}

Clients/src/presentation/pages/ComplianceTracker/1.0ComplianceTracker/ControlsTable.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,16 @@ const ControlsTable: React.FC<ControlsTableProps> = ({
127127

128128
useEffect(() => {
129129
if (controlId) {
130-
// API returns 'control_id' as the actual control ID from controls_eu table
130+
// URL param 'controlId' is the tenant table ID (controls_eu.id)
131+
// Find control by matching control_id (tenant table ID)
131132
const controlExists = controls.find(
132133
(control: any) => control.control_id === Number(controlId)
133134
);
134135
if (controlExists) {
135136
(async () => {
137+
// API expects struct table ID (control.id), not tenant table ID (control.control_id)
136138
const subControlsResponse = await getControlByIdAndProject({
137-
controlId: (controlExists as any).control_id,
139+
controlId: (controlExists as any).id,
138140
projectFrameworkId,
139141
owner: ownerFilter,
140142
approver: approverFilter,

Servers/constants/emailTemplates.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export const EMAIL_TEMPLATES = {
6060
INTAKE_SUBMISSION_APPROVED: "intake-submission-approved.mjml",
6161
INTAKE_SUBMISSION_REJECTED: "intake-submission-rejected.mjml",
6262
INTAKE_NEW_SUBMISSION_ADMIN: "intake-new-submission-admin.mjml",
63+
64+
// Assignment notification template
65+
ASSIGNMENT_NOTIFICATION: "assignment-notification.mjml",
6366
} as const;
6467

6568
// Type-safe template keys

Servers/controllers/eu.ctrl.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Request, Response } from "express";
2+
import { QueryTypes } from "sequelize";
23
import { ControlEU } from "../domain.layer/frameworks/EU-AI-Act/controlEU.model";
4+
import { notifyUserAssigned, AssignmentRoleType } from "../services/inAppNotification.service";
35
import { FileType } from "../domain.layer/models/file/file.model";
46
import { uploadFile } from "../utils/fileUpload.utils";
57
import {
@@ -34,6 +36,112 @@ import {
3436
import logger from "../utils/logger/fileLogger";
3537
import { hasPendingApprovalQuery } from "../utils/approvalRequest.utils";
3638

39+
// Helper function to get user name
40+
async function getUserNameById(userId: number): Promise<string> {
41+
const result = await sequelize.query<{ name: string; surname: string }>(
42+
`SELECT name, surname FROM public.users WHERE id = :userId`,
43+
{ replacements: { userId }, type: QueryTypes.SELECT }
44+
);
45+
if (result[0]) {
46+
return `${result[0].name} ${result[0].surname}`.trim();
47+
}
48+
return "Someone";
49+
}
50+
51+
// Helper function to notify assignment changes for EU AI Act entities
52+
async function notifyEuAiActAssignment(
53+
req: Request | RequestWithFile,
54+
entityType: "EU AI Act Subcontrol",
55+
entityId: number,
56+
entityName: string,
57+
roleType: AssignmentRoleType,
58+
newUserId: number,
59+
oldUserId: number | null | undefined,
60+
projectId?: number,
61+
controlId?: number
62+
): Promise<void> {
63+
// Only notify if assigned to a new user
64+
if (newUserId && newUserId !== oldUserId) {
65+
const assignerName = await getUserNameById(req.userId!);
66+
const baseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
67+
68+
let urlPath: string;
69+
let controlName: string | undefined;
70+
let projectName: string | undefined;
71+
let description: string | undefined;
72+
let resolvedControlId = controlId;
73+
74+
// Query for subcontrol description and order info from struct table
75+
const subcontrolResult = await sequelize.query<{ description: string; control_id: number; subcontrol_order_no: number; control_order_no: number }>(
76+
`SELECT scs.description, sc.control_id, scs.order_no as subcontrol_order_no, cs.order_no as control_order_no
77+
FROM "${req.tenantId!}".subcontrols_eu sc
78+
JOIN public.subcontrols_struct_eu scs ON sc.subcontrol_meta_id = scs.id
79+
JOIN public.controls_struct_eu cs ON scs.control_id = cs.id
80+
WHERE sc.id = :entityId`,
81+
{ replacements: { entityId }, type: QueryTypes.SELECT }
82+
);
83+
description = subcontrolResult[0]?.description;
84+
// Build subcontrol identifier like "1.1 Subcontrol title" (control.order_no.subcontrol.order_no)
85+
if (subcontrolResult[0]) {
86+
entityName = `${subcontrolResult[0].control_order_no}.${subcontrolResult[0].subcontrol_order_no} ${entityName}`;
87+
}
88+
if (!controlId && subcontrolResult[0]?.control_id) {
89+
resolvedControlId = subcontrolResult[0].control_id;
90+
}
91+
92+
// Query for additional context: control name and project name
93+
if (projectId) {
94+
// Get project name
95+
const projectResult = await sequelize.query<{ project_title: string }>(
96+
`SELECT project_title FROM "${req.tenantId!}".projects WHERE id = :projectId`,
97+
{ replacements: { projectId }, type: QueryTypes.SELECT }
98+
);
99+
projectName = projectResult[0]?.project_title;
100+
101+
// Get control name from struct table
102+
if (resolvedControlId) {
103+
const controlResult = await sequelize.query<{ title: string }>(
104+
`SELECT cs.title
105+
FROM "${req.tenantId!}".controls_eu c
106+
JOIN public.controls_struct_eu cs ON c.control_meta_id = cs.id
107+
WHERE c.id = :controlId`,
108+
{ replacements: { controlId: resolvedControlId }, type: QueryTypes.SELECT }
109+
);
110+
controlName = controlResult[0]?.title;
111+
}
112+
}
113+
114+
if (projectId && resolvedControlId) {
115+
urlPath = `/project-view?projectId=${projectId}&tab=frameworks&framework=eu-ai-act&subtab=compliance&controlId=${resolvedControlId}&subControlId=${entityId}`;
116+
} else if (projectId) {
117+
urlPath = `/project-view?projectId=${projectId}&tab=frameworks&framework=eu-ai-act&subtab=compliance`;
118+
} else {
119+
urlPath = `/project-view`;
120+
}
121+
122+
notifyUserAssigned(
123+
req.tenantId!,
124+
newUserId,
125+
{
126+
entityType,
127+
entityId,
128+
entityName,
129+
roleType,
130+
entityUrl: `${baseUrl}${urlPath}`,
131+
},
132+
assignerName,
133+
baseUrl,
134+
{
135+
frameworkName: "EU AI Act",
136+
projectName,
137+
parentType: controlName ? "Control" : undefined,
138+
parentName: controlName,
139+
description,
140+
}
141+
).catch((err) => console.error(`Failed to send ${roleType} notification:`, err));
142+
}
143+
}
144+
37145
export async function getAssessmentsByProjectId(
38146
req: Request,
39147
res: Response
@@ -322,8 +430,32 @@ export async function saveControls(
322430

323431
// now we need to iterate over subcontrols inside the control, and create a subcontrol for each subcontrol
324432
const subControlResp = [];
433+
// Track assignment changes for notifications (sent after transaction commits)
434+
const assignmentChanges: Array<{
435+
subcontrolId: number;
436+
entityName: string;
437+
roleType: AssignmentRoleType;
438+
newUserId: number;
439+
oldUserId: number | null;
440+
}> = [];
441+
325442
if (Control.subControls) {
326443
for (const subcontrol of JSON.parse(Control.subControls)) {
444+
// Get current subcontrol data for assignment change detection
445+
const currentSubcontrolResult = (await sequelize.query(
446+
`SELECT sc.owner, sc.reviewer, sc.approver, scs.title
447+
FROM "${req.tenantId!}".subcontrols_eu sc
448+
JOIN public.subcontrols_struct_eu scs ON sc.subcontrol_meta_id = scs.id
449+
WHERE sc.id = :id;`,
450+
{
451+
replacements: { id: parseInt(subcontrol.id) },
452+
transaction,
453+
type: QueryTypes.SELECT,
454+
}
455+
)) as { owner: number | null; reviewer: number | null; approver: number | null; title: string }[];
456+
457+
const currentSubcontrol = currentSubcontrolResult[0] || { owner: null, reviewer: null, approver: null, title: '' };
458+
327459
const evidenceFiles = ((req.files as UploadedFile[]) || []).filter(
328460
(f) => f.fieldname === `evidence_files_${parseInt(subcontrol.id)}`
329461
);
@@ -404,6 +536,22 @@ export async function saveControls(
404536
);
405537
if (subcontrolToSave) {
406538
subControlResp.push(subcontrolToSave);
539+
540+
// Track assignment changes for notification
541+
const entityName = currentSubcontrol.title || `Subcontrol #${subcontrol.id}`;
542+
const newOwner = subcontrol.owner ? parseInt(String(subcontrol.owner)) : null;
543+
const newReviewer = subcontrol.reviewer ? parseInt(String(subcontrol.reviewer)) : null;
544+
const newApprover = subcontrol.approver ? parseInt(String(subcontrol.approver)) : null;
545+
546+
if (newOwner && newOwner !== currentSubcontrol.owner) {
547+
assignmentChanges.push({ subcontrolId: parseInt(subcontrol.id), entityName, roleType: "Owner", newUserId: newOwner, oldUserId: currentSubcontrol.owner });
548+
}
549+
if (newReviewer && newReviewer !== currentSubcontrol.reviewer) {
550+
assignmentChanges.push({ subcontrolId: parseInt(subcontrol.id), entityName, roleType: "Reviewer", newUserId: newReviewer, oldUserId: currentSubcontrol.reviewer });
551+
}
552+
if (newApprover && newApprover !== currentSubcontrol.approver) {
553+
assignmentChanges.push({ subcontrolId: parseInt(subcontrol.id), entityName, roleType: "Approver", newUserId: newApprover, oldUserId: currentSubcontrol.approver });
554+
}
407555
}
408556
}
409557
}
@@ -419,6 +567,21 @@ export async function saveControls(
419567
);
420568
await transaction.commit();
421569

570+
// Send assignment notifications after transaction commits
571+
for (const change of assignmentChanges) {
572+
notifyEuAiActAssignment(
573+
req,
574+
"EU AI Act Subcontrol",
575+
change.subcontrolId,
576+
change.entityName,
577+
change.roleType,
578+
change.newUserId,
579+
change.oldUserId,
580+
Control.project_id,
581+
controlId
582+
);
583+
}
584+
422585
await logSuccess({
423586
eventType: "Update",
424587
description: `Successfully saved controls for control ID ${controlId}`,

0 commit comments

Comments
 (0)