From 847c019aea4db31f26d90b7e2961d60bcfe68458 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Fri, 30 Jan 2026 19:55:13 +0000 Subject: [PATCH] feat: Add canAssignMultipleUsers field to IncidentRole and update related components for multi-user assignment --- Common/Models/DatabaseModels/IncidentRole.ts | 34 ++ .../1769802715014-MigrationName.ts | 17 + .../Postgres/SchemaMigrations/Index.ts | 2 + Common/Server/Services/ProjectService.ts | 1 + .../MemberRoleAssignment.tsx | 499 +++++++++--------- .../Incident/IncidentMemberRoleAssignment.tsx | 2 + Worker/DataMigrations/Index.ts | 2 + .../UpdateObserverRoleToAllowMultipleUsers.ts | 49 ++ 8 files changed, 366 insertions(+), 240 deletions(-) create mode 100644 Common/Server/Infrastructure/Postgres/SchemaMigrations/1769802715014-MigrationName.ts create mode 100644 Worker/DataMigrations/UpdateObserverRoleToAllowMultipleUsers.ts diff --git a/Common/Models/DatabaseModels/IncidentRole.ts b/Common/Models/DatabaseModels/IncidentRole.ts index fac37da9f3..b419b0f25c 100644 --- a/Common/Models/DatabaseModels/IncidentRole.ts +++ b/Common/Models/DatabaseModels/IncidentRole.ts @@ -493,4 +493,38 @@ export default class IncidentRole extends BaseModel { default: true, }) public isDeleteable?: boolean = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateIncidentRole, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadIncidentRole, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditIncidentRole, + ], + }) + @TableColumn({ + isDefaultValueColumn: true, + type: TableColumnType.Boolean, + title: "Can Assign Multiple Users", + description: + "Can multiple users be assigned to this role? If false, only one user can be assigned.", + }) + @Column({ + type: ColumnType.Boolean, + default: false, + }) + public canAssignMultipleUsers?: boolean = undefined; } diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1769802715014-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1769802715014-MigrationName.ts new file mode 100644 index 0000000000..a6987a41ce --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1769802715014-MigrationName.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1769802715014 implements MigrationInterface { + public name = "MigrationName1769802715014"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "IncidentRole" ADD "canAssignMultipleUsers" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "IncidentRole" DROP COLUMN "canAssignMultipleUsers"`, + ); + } +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index b0f95ed848..df36f39e1c 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -247,6 +247,7 @@ import { MigrationName1769723982900 } from "./1769723982900-MigrationName"; import { MigrationName1769772215532 } from "./1769772215532-MigrationName"; import { MigrationName1769774527481 } from "./1769774527481-MigrationName"; import { MigrationName1769780297584 } from "./1769780297584-MigrationName"; +import { MigrationName1769802715014 } from "./1769802715014-MigrationName"; export default [ InitialMigration, @@ -498,4 +499,5 @@ export default [ MigrationName1769772215532, MigrationName1769774527481, MigrationName1769780297584, + MigrationName1769802715014, ]; diff --git a/Common/Server/Services/ProjectService.ts b/Common/Server/Services/ProjectService.ts index 6092f9c867..25a26c7e0d 100755 --- a/Common/Server/Services/ProjectService.ts +++ b/Common/Server/Services/ProjectService.ts @@ -1006,6 +1006,7 @@ export class ProjectService extends DatabaseService { observer.color = Gray500; observer.roleIcon = IconProp.Activity; observer.projectId = createdItem.id!; + observer.canAssignMultipleUsers = true; observer = await IncidentRoleService.create({ data: observer, diff --git a/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx b/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx index d744fec504..b0aa163906 100644 --- a/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx +++ b/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx @@ -24,6 +24,7 @@ export interface MemberRole { color: Color; isPrimaryRole?: boolean; icon?: IconProp; + canAssignMultipleUsers?: boolean; } export interface AssignedMember { @@ -108,28 +109,39 @@ const MemberRoleAssignment: FunctionComponent = ( [props.onUnassignMember], ); - // Get member for a specific role (only one per role) - const getMemberForRole: (roleId: ObjectID) => AssignedMember | null = + // Get members for a specific role + const getMembersForRole: (roleId: ObjectID) => Array = useCallback( - (roleId: ObjectID): AssignedMember | null => { - return ( - props.assignedMembers.find((member: AssignedMember) => { - return member.roleId.toString() === roleId.toString(); - }) || null - ); + (roleId: ObjectID): Array => { + return props.assignedMembers.filter((member: AssignedMember) => { + return member.roleId.toString() === roleId.toString(); + }); }, [props.assignedMembers], ); - const getUserDropdownOptions: () => Array = - useCallback((): Array => { - return props.availableUsers.map((user: AvailableUser) => { - return { - value: user.id.toString(), - label: user.name || user.email, - }; - }); - }, [props.availableUsers]); + // Get available users for a role (excluding already assigned users for that role) + const getAvailableUsersForRole: (roleId: ObjectID) => Array = + useCallback( + (roleId: ObjectID): Array => { + const assignedUserIds: Set = new Set( + getMembersForRole(roleId).map((m: AssignedMember) => { + return m.userId.toString(); + }), + ); + return props.availableUsers + .filter((user: AvailableUser) => { + return !assignedUserIds.has(user.id.toString()); + }) + .map((user: AvailableUser) => { + return { + value: user.id.toString(), + label: user.name || user.email, + }; + }); + }, + [props.availableUsers, getMembersForRole], + ); const cardTitle: string = props.title || "Team Members"; const cardDescription: string = @@ -159,6 +171,65 @@ const MemberRoleAssignment: FunctionComponent = ( ); } + // Render a single member row + const renderMemberRow: ( + member: AssignedMember, + isLastInGroup?: boolean, + ) => ReactElement = ( + member: AssignedMember, + isLastInGroup?: boolean, + ): ReactElement => { + return ( +
+
+ {member.userProfilePictureUrl ? ( + {member.userName} + ) : ( +
+ + {member.userName?.charAt(0)?.toUpperCase() || + member.userEmail?.charAt(0)?.toUpperCase() || + "?"} + +
+ )} +
+

+ {member.userName || member.userEmail} +

+ {member.userName && member.userEmail && ( +

+ {member.userEmail} +

+ )} +
+
+ +
+ ); + }; + return ( <> = (

) : ( -
- - - - - - - - - - {props.roles.map((role: MemberRole) => { - const member: AssignedMember | null = getMemberForRole( - role.id, - ); - const isDropdownActive: boolean = - activeRoleDropdown?.toString() === role.id.toString(); - const availableUsers: Array = - getUserDropdownOptions(); +
+ {props.roles.map((role: MemberRole) => { + const members: Array = getMembersForRole( + role.id, + ); + const isDropdownActive: boolean = + activeRoleDropdown?.toString() === role.id.toString(); + const availableUsers: Array = + getAvailableUsersForRole(role.id); + const canAddMore: boolean = + role.canAssignMultipleUsers || members.length === 0; - return ( -
- {/* Role Column */} - - - {/* Member Column */} - + {role.isPrimaryRole && ( + + Primary + + )} + {role.canAssignMultipleUsers && ( + + Multiple + + )} + +

+ {members.length}{" "} + {members.length === 1 ? "member" : "members"}{" "} + assigned +

+ + - {/* Action Column */} - - - ); - })} - -
- Role - - Assigned Member - - Action -
-
-
- -
-
- - {role.name} - - {role.isPrimaryRole && ( - - Primary - - )} -
-
-
- {isDropdownActive ? ( -
-
- - | null, - ) => { - if (value && !Array.isArray(value)) { - const option: - | DropdownOption - | undefined = availableUsers.find( - (opt: DropdownOption) => { - return ( - opt.value.toString() === - value.toString() - ); - }, - ); - setSelectedUserOption(option || null); - } else { - setSelectedUserOption(null); - } - }} - value={selectedUserOption || undefined} - /> -
- - -
- ) : member ? ( -
- {/* Avatar */} - {member.userProfilePictureUrl ? ( - {member.userName} - ) : ( -
- - {member.userName?.charAt(0)?.toUpperCase() || - member.userEmail - ?.charAt(0) - ?.toUpperCase() || - "?"} - -
- )} - {/* User Info */} -
-

- {member.userName || member.userEmail} -

- {member.userName && member.userEmail && ( -

- {member.userEmail} -

- )} -
-
- ) : ( - - Not assigned + return ( +
+ {/* Role Header */} +
+
+
+ +
+
+
+ + {role.name} - )} -
- {!isDropdownActive && ( - <> - {member ? ( - - ) : availableUsers.length > 0 ? ( - - ) : ( - - No users - - )} - - )} -
+ {/* Add Button */} + {!isDropdownActive && + canAddMore && + availableUsers.length > 0 && ( + + )} +
+ + {/* Dropdown for adding member */} + {isDropdownActive && ( +
+
+
+ + | null, + ) => { + if (value && !Array.isArray(value)) { + const option: DropdownOption | undefined = + availableUsers.find( + (opt: DropdownOption) => { + return ( + opt.value.toString() === + value.toString() + ); + }, + ); + setSelectedUserOption(option || null); + } else { + setSelectedUserOption(null); + } + }} + value={selectedUserOption || undefined} + /> +
+ + +
+
+ )} + + {/* Members List */} +
+ {members.length === 0 ? ( +

+ Not assigned +

+ ) : ( + members.map( + (member: AssignedMember, index: number) => { + return renderMemberRow( + member, + index === members.length - 1, + ); + }, + ) + )} +
+ + ); + })} )} diff --git a/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx b/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx index ed20e08ade..d3ddc6de15 100644 --- a/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx +++ b/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx @@ -70,6 +70,7 @@ const IncidentMemberRoleAssignment: FunctionComponent = ( color: true, isPrimaryRole: true, roleIcon: true, + canAssignMultipleUsers: true, }, sort: { isPrimaryRole: SortOrder.Descending, @@ -118,6 +119,7 @@ const IncidentMemberRoleAssignment: FunctionComponent = ( color: role.color || Color.fromString("#6b7280"), isPrimaryRole: role.isPrimaryRole || false, icon: role.roleIcon || undefined, + canAssignMultipleUsers: role.canAssignMultipleUsers || false, }; }); diff --git a/Worker/DataMigrations/Index.ts b/Worker/DataMigrations/Index.ts index 7714b33dd4..4efa279999 100644 --- a/Worker/DataMigrations/Index.ts +++ b/Worker/DataMigrations/Index.ts @@ -55,6 +55,7 @@ import LowercaseDomains from "./LowercaseDomains"; import AddAttributeKeysColumnToTelemetryTables from "./AddAttributeKeysColumnToTelemetryTables"; import AddDefaultIncidentRolesToExistingProjects from "./AddDefaultIncidentRolesToExistingProjects"; import AddDefaultIconsToIncidentRoles from "./AddDefaultIconsToIncidentRoles"; +import UpdateObserverRoleToAllowMultipleUsers from "./UpdateObserverRoleToAllowMultipleUsers"; // This is the order in which the migrations will be run. Add new migrations to the end of the array. @@ -114,6 +115,7 @@ const DataMigrations: Array = [ new AddAttributeKeysColumnToTelemetryTables(), new AddDefaultIncidentRolesToExistingProjects(), new AddDefaultIconsToIncidentRoles(), + new UpdateObserverRoleToAllowMultipleUsers(), ]; export default DataMigrations; diff --git a/Worker/DataMigrations/UpdateObserverRoleToAllowMultipleUsers.ts b/Worker/DataMigrations/UpdateObserverRoleToAllowMultipleUsers.ts new file mode 100644 index 0000000000..096b4c612b --- /dev/null +++ b/Worker/DataMigrations/UpdateObserverRoleToAllowMultipleUsers.ts @@ -0,0 +1,49 @@ +import DataMigrationBase from "./DataMigrationBase"; +import { LIMIT_INFINITY } from "Common/Types/Database/LimitMax"; +import IncidentRoleService from "Common/Server/Services/IncidentRoleService"; +import IncidentRole from "Common/Models/DatabaseModels/IncidentRole"; + +export default class UpdateObserverRoleToAllowMultipleUsers extends DataMigrationBase { + public constructor() { + super("UpdateObserverRoleToAllowMultipleUsers"); + } + + public override async migrate(): Promise { + // Get all Observer roles across all projects + const observerRoles: Array = await IncidentRoleService.findBy( + { + query: { + name: "Observer", + }, + select: { + _id: true, + canAssignMultipleUsers: true, + }, + skip: 0, + limit: LIMIT_INFINITY, + props: { + isRoot: true, + }, + }, + ); + + for (const role of observerRoles) { + // Update the role to allow multiple users + if (!role.canAssignMultipleUsers) { + await IncidentRoleService.updateOneById({ + id: role.id!, + data: { + canAssignMultipleUsers: true, + }, + props: { + isRoot: true, + }, + }); + } + } + } + + public override async rollback(): Promise { + return; + } +}