Skip to content
Draft
1 change: 1 addition & 0 deletions api/src/constants/administrative-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export enum ADMINISTRATIVE_ACTIVITY_TYPE {
}

export enum ADMINISTRATIVE_ACTIVITY_STATUS_TYPE {
INVITED = 'Invited',
PENDING = 'Pending',
ACTIONED = 'Actioned',
REJECTED = 'Rejected'
Expand Down
140 changes: 140 additions & 0 deletions api/src/paths/administrative-activity/invited.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import { SYSTEM_ROLE } from '../../constants/roles';
import { getDBConnection } from '../../database/db';
import { HTTP400 } from '../../errors/http-error';
import { authorizeRequestHandler } from '../../request-handlers/security/authorization';
import { AdministrativeActivityService } from '../../services/administrative-activity-service';
import { getLogger } from '../../utils/logger';

const defaultLog = getLogger('paths/administrative-activity/invited');

export const POST: Operation = [
authorizeRequestHandler(() => {
return {
and: [
{
validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR],
discriminator: 'SystemRole'
}
]
};
}),
createInvitedAccessRequest()
];

POST.apiDoc = {
description: 'Create a new invited Administrative Activity for users invited via email.',
tags: ['admin'],
security: [
{
Bearer: []
}
],
requestBody: {
description: 'Invited Administrative Activity post request object.',
required: true,
content: {
'application/json': {
schema: {
title: 'Invited Administrative Activity request object',
type: 'object',
additionalProperties: false,
required: ['systemUserId', 'userGuid', 'email'],
properties: {
systemUserId: { type: 'number', description: 'The system user ID for the invited user.' },
reason: { type: 'string', description: 'Reason for the invitation.' },
userGuid: { type: 'string', description: 'Unique keycloak GUID for the user.' },
name: { type: 'string' },
username: { type: 'string' },
email: { type: 'string' },
identitySource: { type: 'string', description: 'Whether the account is an IDIR or BCeID.' },
company: { type: 'string', description: 'The company that the user belongs to.', nullable: true },
displayName: { type: 'string' }
}
}
}
}
},
responses: {
200: {
description: 'Invited administrative activity post response object.',
content: {
'application/json': {
schema: {
title: 'Invited Administrative Activity Response Object',
type: 'object',
additionalProperties: false,
required: ['id', 'date'],
properties: {
id: {
type: 'number'
},
date: {
description: 'The date this administrative activity was made',
type: 'string'
}
}
}
}
}
},
400: {
$ref: '#/components/responses/400'
},
401: {
$ref: '#/components/responses/401'
},
403: {
$ref: '#/components/responses/403'
},
500: {
$ref: '#/components/responses/500'
},
default: {
$ref: '#/components/responses/default'
}
}
};

/**
* Creates a new administrative activity for an invited access request.
*
* @returns {RequestHandler}
*/
export function createInvitedAccessRequest(): RequestHandler {
return async (req, res) => {
const systemUserId = req.body.systemUserId;

if (!systemUserId) {
throw new HTTP400('Missing required systemUserId');
}

// Extract user data from request body (excluding systemUserId)
const { systemUserId: _, ...userData } = req.body;

Check failure on line 114 in api/src/paths/administrative-activity/invited.ts

View workflow job for this annotation

GitHub Actions / Running Linter and Formatter

'_' is assigned a value but never used

if (!userData.email) {
throw new HTTP400('Missing required email');
}

const connection = getDBConnection(req.keycloak_token);

try {
await connection.open();

const administrativeActivityService = new AdministrativeActivityService(connection);

const response = await administrativeActivityService.createInvitedAccessRequest(systemUserId, userData);

await connection.commit();

return res.status(200).json(response);
} catch (error) {
defaultLog.error({ label: 'createInvitedAccessRequest', message: 'error', error });
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
}
20 changes: 18 additions & 2 deletions api/src/paths/user/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,23 @@ POST.apiDoc = {
},
responses: {
200: {
description: 'Add system user OK.'
description: 'System user created successfully.',
content: {
'application/json': {
schema: {
type: 'object',
additionalProperties: false,
required: ['system_user_id'],
properties: {
system_user_id: {
type: 'integer',
minimum: 1,
description: 'The ID of the created system user'
}
}
}
}
}
},
400: {
$ref: '#/components/responses/400'
Expand Down Expand Up @@ -180,7 +196,7 @@ export function addSystemRoleUser(): RequestHandler {

await connection.commit();

return res.status(200).send();
return res.status(200).json({ system_user_id: userObject.system_user_id });
} catch (error) {
defaultLog.error({ label: 'addSystemRoleUser', message: 'error', error });
await connection.rollback();
Expand Down
55 changes: 55 additions & 0 deletions api/src/repositories/administrative-activity-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,61 @@ export class AdministrativeActivityRepository extends BaseRepository {
return response.rows[0];
}

/**
* Inserts a row in the administrative_activity table reflecting an invited access request.
* Used when users are invited via email to surveys.
*
* @param {number} systemUserId
* @param {(string | object)} data
* @return {*} {Promise<ICreateAdministrativeActivity>}
* @memberof AdministrativeActivityRepository
*/
async createInvitedAccessRequest(
systemUserId: number,
data: string | object
): Promise<ICreateAdministrativeActivity> {
const sqlStatement = SQL`
INSERT INTO administrative_activity (
reported_system_user_id,
administrative_activity_type_id,
administrative_activity_status_type_id,
data
) VALUES (
${systemUserId},
(
SELECT
aat.administrative_activity_type_id
FROM
administrative_activity_type aat
WHERE
aat.name = ${ADMINISTRATIVE_ACTIVITY_TYPE.SYSTEM_ACCESS}
),
(
SELECT
aast.administrative_activity_status_type_id
FROM
administrative_activity_status_type aast
WHERE
aast.name = ${ADMINISTRATIVE_ACTIVITY_STATUS_TYPE.INVITED}
),
${data}
)
RETURNING
administrative_activity_id AS id,
create_date::timestamptz AS date
`;

const response = await this.connection.sql(sqlStatement, ICreateAdministrativeActivity);

if (!response.rows.length) {
throw new ApiExecuteSQLError('Failed to create administrative activity record', [
'AdministrativeActivityRepository->createInvitedAccessRequest'
]);
}

return response.rows[0];
}

/**
* SQL query to count pending records in the administrative_activity table for a given user GUID
*
Expand Down
16 changes: 16 additions & 0 deletions api/src/services/administrative-activity-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ export class AdministrativeActivityService extends DBService {
return this.administrativeActivityRepository.createPendingAccessRequest(systemUserId, data);
}

/**
* Create a new administrative activity record with type "System Access" and status "Invited".
* Used when users are invited via email to surveys.
*
* @param {number} systemUserId
* @param {(string | object)} data
* @return {*} {Promise<ICreateAdministrativeActivity>}
* @memberof AdministrativeActivityService
*/
async createInvitedAccessRequest(
systemUserId: number,
data: string | object
): Promise<ICreateAdministrativeActivity> {
return this.administrativeActivityRepository.createInvitedAccessRequest(systemUserId, data);
}

/**
* Update the status of an existing administrative activity record.
*
Expand Down
1 change: 1 addition & 0 deletions app/src/constants/colours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const NRM_REGION_COLOUR_MAP = {
*
*/
const ACCESS_REQUEST_STATUS_COLOUR_MAP = {
Invited: { colour: blueGrey },
Pending: { colour: purple },
Actioned: { colour: green },
Rejected: { colour: red }
Expand Down
1 change: 1 addition & 0 deletions app/src/constants/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export enum AdministrativeActivityType {
}

export enum AdministrativeActivityStatusType {
INVITED = 'Invited',
PENDING = 'Pending',
ACTIONED = 'Actioned',
REJECTED = 'Rejected'
Expand Down
1 change: 1 addition & 0 deletions app/src/features/admin/AdminManagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const AdminManagePage = () => {
[AdministrativeActivityType.SYSTEM_ACCESS],
[
AdministrativeActivityStatusType.PENDING,
AdministrativeActivityStatusType.INVITED,
AdministrativeActivityStatusType.REJECTED,
AdministrativeActivityStatusType.ACTIONED
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mdiCancel, mdiCheck, mdiExclamationThick } from '@mdi/js';
import { mdiCancel, mdiCheck, mdiEmailPlusOutline, mdiExclamationThick } from '@mdi/js';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import Paper from '@mui/material/Paper';
Expand All @@ -8,6 +8,7 @@ import CustomToggleButtonGroup from 'components/toggle/CustomToggleButtonGroup';
import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface';
import { useState } from 'react';
import AccessRequestActionedList from './list/actioned/AccessRequestActionedList';
import AccessRequestInvitedList from './list/invited/AccessRequestInvitedList';
import AccessRequestPendingList from './list/pending/AccessRequestPendingList';
import AccessRequestRejectedList from './list/rejected/AccessRequestRejectedList';

Expand All @@ -17,6 +18,7 @@ interface IAccessRequestContainerProps {
}

enum AccessRequestViewEnum {
INVITED = 'INVITED',
ACTIONED = 'ACTIONED',
PENDING = 'PENDING',
REJECTED = 'REJECTED'
Expand All @@ -32,18 +34,20 @@ const AccessRequestContainer = (props: IAccessRequestContainerProps) => {
const [activeView, setActiveView] = useState<AccessRequestViewEnum>(AccessRequestViewEnum.PENDING);

const pendingRequests = accessRequests.filter((request) => request.status_name === 'Pending');
const invitedRequests = accessRequests.filter((request) => request.status_name === 'Invited');
const actionedRequests = accessRequests.filter((request) => request.status_name === 'Actioned');
const rejectedRequests = accessRequests.filter((request) => request.status_name === 'Rejected');

const views = [
{ value: AccessRequestViewEnum.PENDING, label: `Pending (${pendingRequests.length})`, icon: mdiExclamationThick },
{ value: AccessRequestViewEnum.INVITED, label: `Invited (${invitedRequests.length})`, icon: mdiEmailPlusOutline },
{ value: AccessRequestViewEnum.ACTIONED, label: `Approved (${actionedRequests.length})`, icon: mdiCheck },
{ value: AccessRequestViewEnum.REJECTED, label: `Rejected (${rejectedRequests.length})`, icon: mdiCancel }
];

return (
<Paper>
<Toolbar>
<Toolbar style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h4" component="h2">
Access Requests
</Typography>
Expand All @@ -64,6 +68,9 @@ const AccessRequestContainer = (props: IAccessRequestContainerProps) => {
{activeView === AccessRequestViewEnum.PENDING && (
<AccessRequestPendingList accessRequests={pendingRequests} refresh={refresh} />
)}
{activeView === AccessRequestViewEnum.INVITED && (
<AccessRequestInvitedList accessRequests={invitedRequests} refresh={refresh} />
)}
{activeView === AccessRequestViewEnum.ACTIONED && (
<AccessRequestActionedList accessRequests={actionedRequests} />
)}
Expand Down
Loading
Loading