diff --git a/.gitignore b/.gitignore index 8423812..279d02a 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,15 @@ task-chain.csv .vscode/launch.json build.cjs ctrl-q +a.json +a1.csv +build-sea.sh +build +certtest.js +logcertfile +sea-config.json +sea-prep.blob +.vscode/launch.json +.vscode/launch.json +certificate.p12 +.vscode/launch.json diff --git a/src/lib/cli/qseow-cp-user-activity-bucket.js b/src/lib/cli/qseow-cp-user-activity-bucket.js index 4ebded9..45ae077 100644 --- a/src/lib/cli/qseow-cp-user-activity-bucket.js +++ b/src/lib/cli/qseow-cp-user-activity-bucket.js @@ -1,59 +1,93 @@ -import { Option } from 'commander'; +import { Option, InvalidArgumentError } from 'commander'; import { catchLog } from '../util/log.js'; -// import { qseowSharedParamAssertOptions, customPropertyUserActivityBucketsAssertOptions } from '../util/qseow/assert-options.js'; -// import customPropertyUserActivityBuckets from '../cmd/qseow/custom-property-user-activity-buckets.js'; +import { qseowSharedParamAssertOptions, userActivityBucketsCustomPropertyAssertOptions } from '../util/qseow/assert-options.js'; +import { createUserActivityBucketsCustomProperty } from '../cmd/qseow/createuseractivitycp.js'; -export function setupQseowUserActivityCustomPropertyCommand(qseow) { - // +// Function to parse update batch size +// Must be a number between 1 and 25 +function parseUpdateBatchSize(value) { + console.log('sdklfjsdlkfjsdlkfjsdlfkj'); + const parsedValue = parseInt(value, 10); + if (isNaN(parsedValue) || parsedValue < 1 || parsedValue > 25) { + throw new InvalidArgumentError('Must be a number between 1 and 25.'); + } + return parsedValue; } -// program -// .command('user-activity-cp-create') -// .description( -// 'create custom property and populate it with values ("activity buckets") indicating how long ago users last logged into Sense' -// ) -// .action(async (options) => { -// try { -// let optionsLocal = options; -// await qseowSharedParamAssertOptions(options); -// optionsLocal = userActivityCustomPropertyAssertOptions(options); -// createUserActivityCustomProperty(optionsLocal); -// } catch (err) { -// logger.error(`USER ACTIVITY CP: ${err}`); -// } -// }) -// .addOption( -// new Option('--log-level ', 'log level') -// .choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']) -// .default('info') -// ) -// .requiredOption('--host ', 'Qlik Sense server IP/FQDN') -// .option('--port ', 'Qlik Sense repository API port', '4242') -// .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') -// .requiredOption('--secure ', 'https connection to Qlik Sense must use correct certificate. Invalid certificates will result in rejected/failed connection.', true) -// .option('--auth-user-dir ', 'user directory for user to connect with', 'Internal') -// .option('--auth-user-id ', 'user ID for user to connect with', 'sa_repository') - -// .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) -// .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') -// .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') -// .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') -// .option('--jwt ', 'JSON Web Token (JWT) to use for authenticating with Qlik Sense', '') - -// .requiredOption('--user-directory ', 'name of user directory whose users will be updated with activity info') -// .requiredOption('--custom-property-name ', 'name of custom property that will hold user activity buckets') -// .addOption( -// new Option('--force ', 'forcibly overwrite and replace custom property and its values if it already exists') -// .choices(['true', 'false']) -// .default('false') -// ) -// .option('--activity-buckets ', 'custom property values/user activity buckets to be defined. In days.', [ -// '1', -// '7', -// '14', -// '30', -// '90', -// '180', -// '365', -// ]); +export function setupQseowUserActivityCustomPropertyCommand(qseow) { + qseow + .command('user-activity-bucket-cp-create') + .description( + 'create custom property and populate it with values ("activity buckets") indicating how long ago users last logged into Sense' + ) + .action(async (options) => { + try { + const newOptions = options; + + await qseowSharedParamAssertOptions(newOptions); + await userActivityBucketsCustomPropertyAssertOptions(newOptions); + + const result = await createUserActivityBucketsCustomProperty(newOptions); + } catch (err) { + catchLog('USER ACTIVITY BUCKET CUSTOM PROPERTY', err); + } + }) + .addOption( + new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') + ) + .requiredOption('--host ', 'Qlik Sense server IP/FQDN') + .option('--port ', 'Qlik Sense repository API port', '4242') + .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') + .requiredOption( + '--secure ', + 'https connection to Qlik Sense must use correct certificate. Invalid certificates will result in rejected/failed connection.', + true + ) + .option('--auth-user-dir ', 'user directory for user to connect with', 'Internal') + .option('--auth-user-id ', 'user ID for user to connect with', 'sa_repository') + + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) + .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') + .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') + .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--jwt ', 'JSON Web Token (JWT) to use for authenticating with Qlik Sense', '') + + .option('--user-directory ', 'name of user directories whose users will be updated with activity info', '') + .addOption( + new Option( + '--license-type ', + 'license type(s) to consider when calculating user activity. Default is all license types.' + ) + .choices(['analyzer', 'analyzer-time', 'login', 'professional', 'user']) + .default(['analyzer', 'analyzer-time', 'login', 'professional', 'user']) + ) + + .requiredOption('--custom-property-name ', 'name of custom property that will hold user activity buckets') + .addOption( + new Option('--force', 'forcibly overwrite and replace custom property and its values if the custom property already exists') + ) + .option( + '--activity-buckets ', + 'custom property values/user activity buckets to be defined. A comma or space separated list of numbers, representing days since last login.', + ['1', '7', '14', '30', '90', '180', '365'] + ) + .option( + '--update-batch-size ', + 'number of users to update in each batch when writing user activity info back into Sense. Valid values are 1-25.', + parseUpdateBatchSize, + 25 + ) + .option( + '--update-batch-sleep ', + 'Wait this long before continuing after each batch of users has been updated in Sense. 0 = no wait.', + 3 + ) + .option( + '--update-user-sleep ', + 'Wait this long after updating each user in the Qlik Sense repository. 0 = no wait.', + 500 + ) + + .option('--dry-run', 'do a dry run, i.e. do not create or update anything - just show what would be done'); +} diff --git a/src/lib/cmd/qseow/createuseractivitycp.js b/src/lib/cmd/qseow/createuseractivitycp.js index 04c6498..079394d 100644 --- a/src/lib/cmd/qseow/createuseractivitycp.js +++ b/src/lib/cmd/qseow/createuseractivitycp.js @@ -1,7 +1,8 @@ -import qrsInteract from 'qrs-interact'; -import path from 'node:path'; -import { logger, setLoggingLevel, isPkg, execPath } from '../../../globals.js'; +import axios from 'axios'; +import { logger, setLoggingLevel, isPkg, execPath, sleep } from '../../../globals.js'; +import { setupQrsConnection } from '../../util/qseow/qrs.js'; +import { catchLog } from '../../util/log.js'; import { getUserActivityProfessional, getUserActivityAnalyzer, @@ -10,7 +11,7 @@ import { getUserActivityUser, getUsersLastActivity, } from './useractivity.js'; -import { catchLog } from '../../util/log.js'; +import { getCustomPropertiesFromQseow, createCustomProperty, updateCustomProperty } from '../../util/qseow/customproperties.js'; const _MS_PER_DAY = 1000 * 60 * 60 * 24; @@ -24,10 +25,45 @@ function dateDiffInDays(a, b) { } /** + * Function to create custom property for tracking user activity in QMC. + * + * - User activity is tracked based on the number of days since the user last logged in. + * - This information is available via QRS API /license//full + * - Possible access license types are: + * - professionalaccesstype + * - analyzeraccesstype + * - analyzertimeaccesstype + * - loginaccesstype + * - useraccesstype + * - A custom property will be set to users based on the number of days since the user last logged in. + * - For example, if the user last logged in 3 days ago, the custom property will be set to 3. + * - The custom property will be created with the name passed in via the command line. + * - The custom property will have a set of allowed values, specified via the command line (or as default values if not specified). + * - Certificate or JWT authentication is used to connect to the Qlik Sense repository service (QRS). * + * General steps: + * - Check if the custom property already exists in QMC + * - If it does not exist, create it + * - If it does exist, check if the allowed values are the same as the ones passed in via the command line + * - If they are different, show a warning and do nothing, unless the --force parameter equals true + * - If --force equals true, delete the existing custom property and create a new one using data from the command line + * - If they are the same, show an info message and continue + * - Get user activity for each access license type enabled via the command line option --license-type + * - Filter QRS call on + * - users' user directory, if specified via the command line + * - users' tag(s), if specified via the command line (future feature) + * - users' custom property value(s), if specified via the command line (future feature) + * - Get user activity for each user. How many days ago was the user last active? + * - Calculate activity buckets for all users (matching command line filters) before writing back to QRS + * - Update users in QRS with the user activity custom property value + * - Update batches of users using the QRS API endpoint POST /user/many + * - Batch size determined by command line parameter --update-batch-size + * - Wait for a short time between each batch, to avoid overloading the QRS. Delay is determined by command line parameter --update-batch-sleep + * + * If the process above fails at some point, show an error message and return with false. * @param {*} options */ -const createUserActivityCustomProperty = async (options) => { +export async function createUserActivityBucketsCustomProperty(options) { try { // Set log level setLoggingLevel(options.logLevel); @@ -35,177 +71,520 @@ const createUserActivityCustomProperty = async (options) => { logger.verbose(`Ctrl-Q was started as a stand-alone binary: ${isPkg}`); logger.verbose(`Ctrl-Q was started from ${execPath}`); - logger.info('Create custom property for tracking user activity in QMC'); + logger.info('== Step 1: Create custom property for tracking user activity in QMC'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Set up connection to Sense repository service - const certPath = path.resolve(process.cwd(), options.authCertFile); - const keyPath = path.resolve(process.cwd(), options.authCertKeyFile); - - // Verify cert files exist - - const configQRS = { - hostname: options.host, - portNumber: options.port, - certificates: { - certFile: certPath, - keyFile: keyPath, - }, - }; - - configQRS.headers = { - 'X-Qlik-User': 'UserDirectory=Internal; UserId=sa_repository', - 'Content-Type': 'application/json', - }; - - const qrsInteractInstance = new qrsInteract(configQRS); - let result; - - // Does CP already exist? - try { - result = await qrsInteractInstance.Get(`custompropertydefinition/full?filter=name eq '${options.customPropertyName}'`); - } catch (err) { - // Return error msg - catchLog(`USER ACTIVITY CP: Error getting user activity custom property`, err); - } + // Sort activity buckets passed via command line in ascending order + // When creating new or updating existing custom property, the allowed values should be sorted in ascending order + const activityBucketsSorted = options.activityBuckets.sort((a, b) => a - b); + + // Get custom properties from QSEoW + let customProperties = await getCustomPropertiesFromQseow(options); + logger.info(` Successfully retrieved ${customProperties.length} custom properties from QSEoW`); - if (result.statusCode === 200) { - if (result.body.length === 1) { - // CP exists - logger.debug(`USER ACTIVITY CP: Custom property name passed via command line exists`); + // Does the custom property already exist in QMC? + let customPropertyExisting = customProperties.find((cp) => cp.name === options.customPropertyName); + + if (customPropertyExisting) { + // A custom property with correct name already exists + + // Does the existing CP have *exactly* the same choice-values as passed in via command line? + if (activityBucketsSorted.length === customPropertyExisting.choiceValues.length) { + // Same number of custom property values. Are they the same and in same order? + let keepExistingCustomProperty = true; + + for (let i = 0; i < activityBucketsSorted.length; i++) { + if (activityBucketsSorted[i] !== customPropertyExisting.choiceValues[i]) { + keepExistingCustomProperty = false; + break; + } + } - // Does the existing CP have *exactly* the same values as passed in via comand line? - if (options.activityBuckets.length === result.body[0].choiceValues.length) { - // Same number of custom property values. Are they the same? + if (keepExistingCustomProperty) { + // Custom property already exists with the same allowed values, in the same order, as passed in via command line. + // Show info message and continue, no need to modify the existing custom property + logger.info( + ` Custom property "${options.customPropertyName}" already exists with the same allowed values as passed in via command line. No action needed.` + ); } else { - // Different number of values. Do nothing, unless the --force paramerer equals true - if (options.force === 'false') { - // Don't force overwrite the existni custom property. - // Show warning and return - logger.warn( - `USER ACTIVITY CP: Custom property already exists, with existing values different from the ones pass in via command line. Aborting.` + logger.warn( + `Custom property already exists, but existing values are different from the ones passed in via command line.` + ); + logger.warn(`Allowed values for existing custom property: ${customPropertyExisting.choiceValues}`); + logger.warn(`Allowed values (sorted ascending) passed in via command line: ${activityBucketsSorted}`); + + // Do nothing, unless the --force parameter equals true + if (options.force === 'true' || options.force === true) { + // Force overwrite the existing custom property + logger.info( + ` Option "--force" specified, updating custom property ${options.customPropertyName} with new allowed values.` ); + + // Update existing custom property + // First copy existing custom property to a new object, then replace the choiceValues with the new ones + const customPropertyDefinition = JSON.parse(JSON.stringify(customPropertyExisting)); + customPropertyDefinition.choiceValues = activityBucketsSorted; + + const result = await updateCustomProperty(options, customPropertyDefinition); + if (result) { + logger.verbose( + ` Updated existing custom property "${options.customPropertyName}" with new allowed values passed in via command line.` + ); + } else { + logger.error( + `Failed to update existing custom property "${options.customPropertyName}" with new allowed values.` + ); + return false; + } } else { - // - logger.verbose(`USER ACTIVITY CP: Replacing custom property ${options.customPropertyName}`); + // Don't force overwrite the existing custom property. + // Show warning and return + logger.warn(`"--force" option not specified. Aborting.`); + return false; } } - } else if (result.body.length === 0) { - // CP does not exist - logger.debug(`USER ACTIVITY CP: Custom property name passed via command line does not exist`); - - // Create new CP - try { - result = await qrsInteractInstance.Post( - 'custompropertydefinition', - { - name: options.customPropertyName, - valueType: 'Text', - // choiceValues: ['1', '7', '14'], - choiceValues: options.activityBuckets, - objectTypes: ['User'], - description: 'Ctrl-Q user activity buckets', - }, - 'json' + } else { + // Custom property exists, but has different number of values compared to command line options. + // Do nothing unless the --force paramerer equals true + if (options.force === 'false' || options.force === false || options.force === undefined) { + // Don't force overwrite the existni custom property. + // Show warning and return + logger.warn( + `Custom property "${options.customPropertyName}" already exists, but has different allowed values compared to the ones passed in via command line. Use the --force option to overwrite the existing custom property.` ); - } catch (err) { - catchLog(`USER ACTIVITY CP: Error creating user activity custom property`, err); - } + logger.warn(`Use the --force option to overwrite the existing custom property.`); + return false; + } else { + // Force replace the existing custom property + logger.verbose(` Replacing custom property ${options.customPropertyName}`); - if (result.statusCode === 201) { - logger.verbose(`USER ACTIVITY CP: Created new custom property "${options.customPropertyName}"`); + // Update existing custom property + // First copy existing custom property to a new object, then replace the choiceValues with the new ones + const customPropertyDefinition = JSON.parse(JSON.stringify(customPropertyExisting)); + customPropertyDefinition.choiceValues = activityBucketsSorted; + + const result = await updateCustomProperty(options, customPropertyDefinition); + if (result) { + logger.verbose( + ` Updated existing custom property "${options.customPropertyName}" with new allowed values passed in via command line.` + ); + } else { + logger.error(`Failed to update existing custom property "${options.customPropertyName}" with new allowed values.`); + return false; + } } } + } else { + // Custom property does not exist. Create it. + + // Create custom property definition/payload to QRS POST call + const customPropertyDefinition = { + valueType: 'Text', + schemaPath: 'CustomPropertyDefinition', + objectTypes: ['User'], + name: options.customPropertyName, + description: 'Ctrl-Q user activity bucket', + choiceValues: activityBucketsSorted, + }; + + const result = await createCustomProperty(options, customPropertyDefinition); + if (result) { + logger.verbose(` Created custom property "${options.customPropertyName}"`); + } else { + logger.error(`Failed to create custom property "${options.customPropertyName}"`); + return false; + } + } - // User activity info will available in following format + // Get custom property again, as it has potentially been created or updated + customProperties = await getCustomPropertiesFromQseow(options); + + // Does the custom property already exist in QMC? + customPropertyExisting = customProperties.find((cp) => cp.name === options.customPropertyName); + + // Get user activity for each access license type enabled via the command line option --license-type + // Filter QRS call on + // - users' user directory, if specified via the command line + // If user directory is not specified, no filtering on user directory will be done + + let activityAnalyzer = []; + let activityAnalyzerTime = []; + let activityLogin = []; + let activityProfessional = []; + let activityUser = []; + + logger.info(''); + logger.info(`== Step 2 : Getting user activity for each license type enabled via the command line...`); + + // Is "analyzer" license type enabled? + if (options.licenseType.includes('analyzer')) { + // Get user activity for analyzer license type // Array of objects: // { - // id: "41e8464e-87ed-4ea3-9fc7-e09d2dc6781a", - // createdDate: "2021-11-19T12:23:58.850Z", - // modifiedDate: "2022-08-27T06:47:08.600Z", - // modifiedByUserName: "LAB\\testuser_2", - // user: { - // id: "9e403391-58a7-4442-ada7-c54dc8906016", - // userId: "testuser_2", - // userDirectory: "LAB", - // userDirectoryConnectorName: "LAB", - // name: "Testuser2", - // privileges: null, + // "privileges" : [ "privileges", "privileges" ], + // "quarantineEnd" : "2000-01-23T04:56:07.000+00:00", + // "schemaPath" : "schemaPath", + // "quarantined" : true, + // "deletedUserId" : "deletedUserId", + // "lastUsed" : "2000-01-23T04:56:07.000+00:00", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "deletedUserDirectory" : "deletedUserDirectory", + // "excess" : true, + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "user" : { + // "privileges" : [ "privileges", "privileges" ], + // "userDirectory" : "userDirectory", + // "userDirectoryConnectorName" : "userDirectoryConnectorName", + // "name" : "name", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "userId" : "userId" + // } + // } + activityAnalyzer = await getUserActivityAnalyzer(options); + logger.debug(` Analyzer licenses: ${JSON.stringify(activityAnalyzer)}`); + } + + // Is "analyzer-time" license type enabled? + if (options.licenseType.includes('analyzer-time')) { + // Get user activity for analyzer-time license type + // Array of objects: + // { + // "latestActivity" : "2000-01-23T04:56:07.000+00:00", + // "privileges" : [ "privileges", "privileges" ], + // "hostName" : "hostName", + // "sessions" : [ { + // "latestActivity" : "2000-01-23T04:56:07.000+00:00", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "schemaPath" : "schemaPath", + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "sessionID" : "sessionID", + // "serverNodeConfigurationId" : "serverNodeConfigurationId" + // }, { + // "latestActivity" : "2000-01-23T04:56:07.000+00:00", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "schemaPath" : "schemaPath", + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "sessionID" : "sessionID", + // "serverNodeConfigurationId" : "serverNodeConfigurationId" + // } ], + // "useStopTime" : "2000-01-23T04:56:07.000+00:00", + // "useStartTime" : "2000-01-23T04:56:07.000+00:00", + // "schemaPath" : "schemaPath", + // "serverNodeConfigurationId" : "serverNodeConfigurationId", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "analyzerTimeAccessType" : { + // "privileges" : [ "privileges", "privileges" ], + // "name" : "name", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91" // }, - // lastUsed: "2022-08-27T06:47:08.584Z", - // excess: false, - // quarantined: false, - // quarantineEnd: "1753-01-01T00:00:00.000Z", - // deletedUserId: "", - // deletedUserDirectory: "", - // privileges: null, - // schemaPath: "License.AnalyzerAccessType", - // } - - // Get user activity via QRS API, per license type - const activityProfessional = await getUserActivityProfessional(qrsInteractInstance); - logger.debug(`USER ACTIVITY CP: Professional licenses: ${JSON.stringify(activityProfessional)}`); - - const activityAnalyzer = await getUserActivityAnalyzer(qrsInteractInstance); - logger.debug(`USER ACTIVITY CP: Analyzer licenses: ${JSON.stringify(activityAnalyzer)}`); - - const activityAnalyzerTime = await getUserActivityAnalyzerTime(qrsInteractInstance); - logger.debug(`USER ACTIVITY CP: Analyzer time licenses: ${JSON.stringify(activityAnalyzerTime)}`); - - const activityLogin = await getUserActivityLogin(qrsInteractInstance); - logger.debug(`USER ACTIVITY CP: Login licenses: ${JSON.stringify(activityLogin)}`); - - const activityUser = await getUserActivityUser(qrsInteractInstance); - logger.debug(`USER ACTIVITY CP: User licenses: ${JSON.stringify(activityUser)}`); - - const usersLastActivity = await getUsersLastActivity( - activityProfessional, - activityAnalyzer, - activityAnalyzerTime, - activityLogin, - activityUser - ); - - // Assign users to activity buckets - for (const user of usersLastActivity) { - // How many days ago was user active? Round down to nearest full day - const dateNow = new Date(); - const dateUserLastActivity = new Date(user.lastUsed); - // const diffTime = Math.abs(dateNow - dateUserLastActivity); - // const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - const diffDays = dateDiffInDays(dateUserLastActivity, dateNow); - - for (const bucket of options.activityBuckets) { - if (diffDays <= bucket) { - user.activityBucket = bucket; - break; - } + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "user" : { + // "privileges" : [ "privileges", "privileges" ], + // "userDirectory" : "userDirectory", + // "userDirectoryConnectorName" : "userDirectoryConnectorName", + // "name" : "name", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "userId" : "userId" + // } + // } + activityAnalyzerTime = await getUserActivityAnalyzerTime(options); + logger.debug(` Analyzer time licenses: ${JSON.stringify(activityAnalyzerTime)}`); + } + + // Is "login" license type enabled? + if (options.licenseType.includes('login')) { + // Get user activity for login license type + // Array of objects: + // { + // "latestActivity" : "2000-01-23T04:56:07.000+00:00", + // "privileges" : [ "privileges", "privileges" ], + // "hostName" : "hostName", + // "sessions" : [ { + // "latestActivity" : "2000-01-23T04:56:07.000+00:00", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "schemaPath" : "schemaPath", + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "sessionID" : "sessionID", + // "serverNodeConfigurationId" : "serverNodeConfigurationId" + // }, { + // "latestActivity" : "2000-01-23T04:56:07.000+00:00", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "schemaPath" : "schemaPath", + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "sessionID" : "sessionID", + // "serverNodeConfigurationId" : "serverNodeConfigurationId" + // } ], + // "useStopTime" : "2000-01-23T04:56:07.000+00:00", + // "useStartTime" : "2000-01-23T04:56:07.000+00:00", + // "schemaPath" : "schemaPath", + // "serverNodeConfigurationId" : "serverNodeConfigurationId", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "loginAccessType" : { + // "privileges" : [ "privileges", "privileges" ], + // "name" : "name", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91" + // }, + // "modifiedByUserName" : "modifiedByUserName", + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "user" : { + // "privileges" : [ "privileges", "privileges" ], + // "userDirectory" : "userDirectory", + // "userDirectoryConnectorName" : "userDirectoryConnectorName", + // "name" : "name", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "userId" : "userId" + // } + // } + activityLogin = await getUserActivityLogin(options); + logger.debug(` Login licenses: ${JSON.stringify(activityLogin)}`); + } + + // Is "professional" license type enabled? + if (options.licenseType.includes('professional')) { + // Get user activity for professional license type + // Array of objects: + // { + // "privileges" : [ "privileges", "privileges" ], + // "quarantineEnd" : "2000-01-23T04:56:07.000+00:00", + // "schemaPath" : "schemaPath", + // "quarantined" : true, + // "deletedUserId" : "deletedUserId", + // "lastUsed" : "2000-01-23T04:56:07.000+00:00", + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "deletedUserDirectory" : "deletedUserDirectory", + // "excess" : true, + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "user" : { + // "privileges" : [ "privileges", "privileges" ], + // "userDirectory" : "userDirectory", + // "userDirectoryConnectorName" : "userDirectoryConnectorName", + // "name" : "name", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "userId" : "userId" + // } + // } + activityProfessional = await getUserActivityProfessional(options); + logger.debug(` Professional licenses: ${JSON.stringify(activityProfessional)}`); + } + + // Is "user" license type enabled? + if (options.licenseType.includes('user')) { + // Get user activity for user license type + // Array of objects: + // { + // "lastUsed" : "2000-01-23T04:56:07.000+00:00", + // "privileges" : [ "privileges", "privileges" ], + // "createdDate" : "2000-01-23T04:56:07.000+00:00", + // "quarantineEnd" : "2000-01-23T04:56:07.000+00:00", + // "modifiedByUserName" : "modifiedByUserName", + // "deletedUserDirectory" : "deletedUserDirectory", + // "schemaPath" : "schemaPath", + // "modifiedDate" : "2000-01-23T04:56:07.000+00:00", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "quarantined" : true, + // "user" : { + // "privileges" : [ "privileges", "privileges" ], + // "userDirectory" : "userDirectory", + // "userDirectoryConnectorName" : "userDirectoryConnectorName", + // "name" : "name", + // "id" : "046b6c7f-0b8a-43b9-b35d-6489e6daee91", + // "userId" : "userId" + // }, + // "deletedUserId" : "deletedUserId" + // } + activityUser = await getUserActivityUser(options); + logger.debug(` User licenses: ${JSON.stringify(activityUser)}`); + } + + const usersLastActivity = await getUsersLastActivity( + activityAnalyzer, + activityAnalyzerTime, + activityLogin, + activityProfessional, + activityUser + ); + + // Assign users to activity buckets + logger.info(''); + logger.info(`== Step 3 : Calculate days since last activity for each user...`); + + for (const user of usersLastActivity) { + // How many days ago was user active? Round down to nearest full day + const dateNow = new Date(); + const dateUserLastActivity = new Date(user.lastUsed); + const diffDays = dateDiffInDays(dateUserLastActivity, dateNow); + + for (const bucket of activityBucketsSorted) { + // Assign user to activity bucket that is equal to or greater than the number of days since last activity + if (diffDays <= bucket) { + user.activityBucket = bucket; + break; + } + } + } + logger.verbose(` Assigned activity buckets to users via custom property ${options.customPropertyName}`); + + // Update data in QRS + // Batch updates to avoid overloading QRS by calling once for each user + // Batch size determined by command line parameter --update-batch-size + // Wait for a short time between each batch, to avoid overloading the QRS. Delay is determined by command line parameter --update-batch-sleep + const batchSize = options.updateBatchSize; + const batchSleep = options.updateBatchSleep * 1000; // Convert seconds to milliseconds + const totalBatches = Math.ceil(usersLastActivity.length / batchSize); + const outputUserArray = []; + + logger.info(''); + logger.info(`== Step 4 : Get user data from Sense, one batch at a time (each batch is ${batchSize} users)...`); + logger.info(` Total number of users to process: ${usersLastActivity.length}`); + logger.info(` Total number of batches: ${totalBatches}`); + + for (let i = 0; i < totalBatches; i++) { + const start = i * batchSize; + const end = start + batchSize; + const usersBatch = usersLastActivity.slice(start, end); + + logger.info(''); + logger.info(` >> Batch ${i + 1} of ${totalBatches} (users ${start + 1} to ${end})`); + logger.info(` Calculating activity buckets`); + + // Get full user data from QRS for users in this batch + // Users are identified by usersBartch[i].userSenseId + // This is the user ID in Qlik Sense + + // First create filter string for QRS call. Format is + // filter=userId eq 'user1.id' or userId eq 'user2.id' or userId eq 'user3.id' + const filter = usersBatch.map((user) => { + return `id eq ${user.userSenseId}`; + }); + const filterString = filter.join(' or '); + logger.debug(` Filter string for getting user batch ${i} from QRS: ${filterString}`); + + const axiosConfig = setupQrsConnection(options, { + method: 'get', + path: 'qrs/user/full', + queryParameters: [ + { + name: 'filter', + value: filterString, + }, + ], + }); + + // Get current info from QRS for this batch of users + const result = await axios.request(axiosConfig); + let currentUserInfo; + if (result.status === 200) { + currentUserInfo = JSON.parse(result.data); + logger.debug(` Response from QRS for getting user batch ${i}: ${result.status}`); + } else { + logger.error(`Error ${result.status} getting user batch ${i} from QRS`); + return false; + } + + // Update user objects with activity buckets + logger.info(` Preparing user activity custom property`); + + for (const currentUser of currentUserInfo) { + // user is a full user object from QRS + + const userActivityCp = currentUser.customProperties.find((cp) => cp.definition.name === options.customPropertyName); + + if (userActivityCp) { + // User activity custom property already exists + // Remove it + currentUser.customProperties = currentUser.customProperties.filter( + (cp) => cp.definition.name !== options.customPropertyName + ); } + // Custom property does not exist for this user, add it + // Note that the custom property itself however exists, it's just not assigned to this user (yet) + currentUser.customProperties.push({ + definition: { + valueType: 'Text', + name: customPropertyExisting.name, + id: customPropertyExisting.id, + choiceValues: customPropertyExisting.choiceValues, + }, + value: usersBatch.find((user) => user.userSenseId === currentUser.id).activityBucket.toString(), + }); + + // Add user to output array + outputUserArray.push(currentUser); + } + + // Pause options.updateBatchSleep seconds before next batch + logger.info(` Pausing ${options.updateBatchSleep} seconds before next batch...`); + if (batchSleep > 0) await sleep(batchSleep); + } + + logger.info(''); + logger.info(`Done calculating activity buckets for all users. Proceeding to update user activity custom property in Qlik Sense.`); - // Set custom property for user - try { - result = await qrsInteractInstance.Post( - 'custompropertydefinition', - { - name: options.customPropertyName, - valueType: 'Text', - // choiceValues: ['1', '7', '14'], - choiceValues: options.activityBuckets, - objectTypes: ['User'], - description: 'Ctrl-Q user activity buckets', - }, - 'json' + logger.info(''); + logger.info(`== Step 5 : Update user activity custom property in Qlik Sense.`); + + // Update user activity custom property in Qlik Sense + // Loop over the same buckets, using the same batch size. + // Data to be sent to QRS is the outputUserArray array + const totalOutputBatches = Math.ceil(outputUserArray.length / batchSize); + logger.info(` Number of batches to process: ${totalOutputBatches} of ${batchSize} users each.`); + + let userCounter = 1; + for (let i = 0; i < totalOutputBatches; i++) { + const start = i * batchSize; + const end = start + batchSize; + const usersBatch = outputUserArray.slice(start, end); + + logger.info(` Storing activity buckets for batch ${i + 1} of ${totalBatches} in Sense repository.`); + + // Loop over the users in the batch, writing the user activity custom property to QRS + for (const user of usersBatch) { + // Payload: array of user objects + const axiosConfig = setupQrsConnection(options, { + method: 'put', + path: `qrs/user/${user.id}`, + body: user, + }); + + const result = await axios.request(axiosConfig); + if (result.status === 200) { + logger.info( + ` Updated user ${userCounter} of ${outputUserArray.length}, "${user.userDirectory}\\${user.userId}" in batch ${ + i + 1 + } of ${totalBatches}` ); - } catch (err) { - catchLog(`USER ACTIVITY CP: Error creating user activity custom property`, err); + } else { + logger.error(`Error ${result.status} updating user activity custom property for batch ${i + 1} of ${totalBatches}`); + return false; } + userCounter++; + + // Pause half a second between each user + if (options.updateUserSleep > 0) await sleep(options.updateUserSleep); } - logger.verbose(`USER ACTIVITY CP: Assigned activity buckets to users via custom property ${options.customPropertyName}`); } + + logger.info(''); + logger.info(`Done updating user activity custom property in Qlik Sense.`); + + return true; } catch (err) { // Return error msg - catchLog(`USER ACTIVITY CP: Error creating user activity custom property`, err); + catchLog(`Error creating user activity custom property`, err); } -}; - -export default createUserActivityCustomProperty; +} diff --git a/src/lib/cmd/qseow/useractivity.js b/src/lib/cmd/qseow/useractivity.js index 319cd42..05c2383 100644 --- a/src/lib/cmd/qseow/useractivity.js +++ b/src/lib/cmd/qseow/useractivity.js @@ -1,187 +1,241 @@ -import { logger } from '../../../globals.js'; +import axios from 'axios'; +import path from 'node:path'; + +import { logger, execPath } from '../../../globals.js'; import { catchLog } from '../../util/log.js'; +import { setupQrsConnection } from '../../util/qseow/qrs.js'; -export function getUserActivityProfessional(qrsInteractInstance) { - // eslint-disable-next-line no-unused-vars, no-async-promise-executor - return new Promise(async (resolve, _reject) => { - let result; - try { - result = await qrsInteractInstance.Get('license/professionalaccesstype/full'); - } catch (err) { - catchLog(`USER ACTIVITY PROFESSIONAL: Error getting user activity info from QRS`, err); - } +// Function to get user activity from QRS for license type "Analyzer" +export async function getUserActivityAnalyzer(options) { + logger.verbose(`Getting user activity for license type "Analyzer"...`); - resolve(result.body); - }); -} + try { + const axiosConfig = setupQrsConnection(options, { + method: 'get', + path: '/qrs/license/analyzeraccesstype/full', + }); -export function getUserActivityAnalyzer(qrsInteractInstance) { - // eslint-disable-next-line no-unused-vars, no-async-promise-executor - return new Promise(async (resolve, _reject) => { - let result; - try { - result = await qrsInteractInstance.Get('license/analyzeraccesstype/full'); - } catch (err) { - catchLog(`USER ACTIVITY ANALYZER: Error getting user activity info from QRS`, err); - } + const result = await axios.request(axiosConfig); + if (result.status === 200) { + const response = JSON.parse(result.data); + logger.info(` Successfully retrieved ${response.length} user activity records for license type "Analyzer" from QSEoW`); - resolve(result.body); - }); + return response; + } + return false; + } catch (err) { + catchLog(`USER ACTIVITY ANALYZER: Error getting user activity info from QRS`, err); + return false; + } } -export function getUserActivityAnalyzerTime(qrsInteractInstance) { - // eslint-disable-next-line no-unused-vars, no-async-promise-executor - return new Promise(async (resolve, _reject) => { - let result; - try { - result = await qrsInteractInstance.Get('license/analyzertimeaccesstype/full'); - } catch (err) { - catchLog(`USER ACTIVITY ANALYZER TIME: Error getting user activity info from QRS`, err); - } +// Function to get user activity from QRS for license type "Analyzer Time" +export async function getUserActivityAnalyzerTime(options) { + logger.verbose(`Getting user activity for license type "Analyzer Time"...`); - resolve(result.body); - }); -} + try { + const axiosConfig = setupQrsConnection(options, { + method: 'get', + path: '/qrs/license/analyzertimeaccessusage/full', + }); -export function getUserActivityLogin(qrsInteractInstance) { - // eslint-disable-next-line no-unused-vars, no-async-promise-executor - return new Promise(async (resolve, _reject) => { - let result; - try { - result = await qrsInteractInstance.Get('license/loginaccesstype/full'); - } catch (err) { - catchLog(`USER ACTIVITY LOGIN: Error getting user activity info from QRS`, err); - } + const result = await axios.request(axiosConfig); + if (result.status === 200) { + const response = JSON.parse(result.data); + logger.info(` Successfully retrieved ${response.length} user activity records for license type "Analyzer Time" from QSEoW`); - resolve(result.body); - }); + return response; + } + return false; + } catch (err) { + catchLog(`USER ACTIVITY ANALYZER TIME: Error getting user activity info from QRS`, err); + return false; + } } -export function getUserActivityUser(qrsInteractInstance) { - // eslint-disable-next-line no-unused-vars, no-async-promise-executor - return new Promise(async (resolve, _reject) => { - let result; - try { - result = await qrsInteractInstance.Get('license/useraccesstype/full'); - } catch (err) { - catchLog(`USER ACTIVITY USER: Error getting user activity info from QRS`, err); - } +// Function to get user activity from QRS for license type "Login" +export async function getUserActivityLogin(options) { + logger.verbose(`Getting user activity for license type "Login"...`); - resolve(result.body); - }); -} + try { + const axiosConfig = setupQrsConnection(options, { + method: 'get', + path: '/qrs/license/loginaccessusage/full', + }); -export function getUsersLastActivity(activityProfessional, activityAnalyzer, activityAnalyzerTime, activityLogin, activityUser) { - // eslint-disable-next-line no-unused-vars, no-async-promise-executor - return new Promise(async (resolve, _reject) => { - const usersActivity = []; - - // eslint-disable-next-line no-restricted-syntax - for (const user of activityProfessional) { - // Does this user already exist in user activity array? - if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { - // User ID has already been added (seems it appears in more than one activity type!) - // Pick the most recent last activity date - logger.debug( - `USER ACTIVITY PROFESSIONAL: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` - ); - } else { - // User ID has not been added yet. Add it! - usersActivity.push({ - userSenseId: user.user.id, - userId: user.user.userId, - userDirectory: user.user.userDirectory, - userName: user.user.name, - lastUsed: user.lastUsed, - }); - } - } + const result = await axios.request(axiosConfig); + if (result.status === 200) { + const response = JSON.parse(result.data); + logger.info(` Successfully retrieved ${response.length} user activity records for license type "Login" from QSEoW`); - // eslint-disable-next-line no-restricted-syntax - for (const user of activityAnalyzer) { - // Does this user already exist in user activity array? - if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { - // User ID has already been added (seems it appears in more than one activity type!) - // Pick the most recent last activity date - logger.debug( - `USER ACTIVITY ANALYZER: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` - ); - } else { - // User ID has not been added yet. Add it! - usersActivity.push({ - userSenseId: user.user.id, - userId: user.user.userId, - userDirectory: user.user.userDirectory, - userName: user.user.name, - lastUsed: user.lastUsed, - }); - } + return response; } + return false; + } catch (err) { + catchLog(`USER ACTIVITY LOGIN: Error getting user activity info from QRS`, err); + return false; + } +} - // eslint-disable-next-line no-restricted-syntax - for (const user of activityAnalyzerTime) { - // Does this user already exist in user activity array? - if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { - // User ID has already been added (seems it appears in more than one activity type!) - // Pick the most recent last activity date - logger.debug( - `USER ACTIVITY ANALYZER TIME: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` - ); - } else { - // User ID has not been added yet. Add it! - usersActivity.push({ - userSenseId: user.user.id, - userId: user.user.userId, - userDirectory: user.user.userDirectory, - userName: user.user.name, - lastUsed: user.lastUsed, - }); - } +// Function to get user activity from QRS for license type "Professional" +export async function getUserActivityProfessional(options) { + logger.verbose(`Getting user activity for license type "Professional"...`); + + try { + const axiosConfig = setupQrsConnection(options, { + method: 'get', + path: '/qrs/license/professionalaccesstype/full', + }); + + const result = await axios.request(axiosConfig); + if (result.status === 200) { + const response = JSON.parse(result.data); + logger.info(` Successfully retrieved ${response.length} user activity records for license type "Professional" from QSEoW`); + + return response; } + return false; + } catch (err) { + catchLog(`USER ACTIVITY PROFESSIONAL: Error getting user activity info from QRS`, err); + return false; + } +} + +// Function to get user activity from QRS for license type "User" +export async function getUserActivityUser(options) { + logger.verbose(`Getting user activity for license type "User"...`); + + try { + const axiosConfig = setupQrsConnection(options, { + method: 'get', + path: '/qrs/license/useraccessusage/full', + }); - // eslint-disable-next-line no-restricted-syntax - for (const user of activityLogin) { - // Does this user already exist in user activity array? - if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { - // User ID has already been added (seems it appears in more than one activity type!) - // Pick the most recent last activity date - logger.debug( - `USER ACTIVITY LOGIN: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` - ); - } else { - // User ID has not been added yet. Add it! - usersActivity.push({ - userSenseId: user.user.id, - userId: user.user.userId, - userDirectory: user.user.userDirectory, - userName: user.user.name, - lastUsed: user.lastUsed, - }); - } + const result = await axios.request(axiosConfig); + if (result.status === 200) { + const response = JSON.parse(result.data); + logger.info(` Successfully retrieved ${response.length} user activity records for license type "User" from QSEoW`); + + return response; } + return false; + } catch (err) { + catchLog(`USER ACTIVITY USER: Error getting user activity info from QRS`, err); + return false; + } +} - // eslint-disable-next-line no-restricted-syntax - for (const user of activityUser) { - // Does this user already exist in user activity array? - if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { - // User ID has already been added (seems it appears in more than one activity type!) - // Pick the most recent last activity date - logger.debug( - `USER ACTIVITY USER: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` - ); - } else { - // User ID has not been added yet. Add it! - usersActivity.push({ - userSenseId: user.user.id, - userId: user.user.userId, - userDirectory: user.user.userDirectory, - userName: user.user.name, - lastUsed: user.lastUsed, - }); - } +// Function to extract the last activity date for the different license types. +// +// Return: +// An array of objects, each object containing +// - user directory +// - user ID +// - user name +// - last activity date +export async function getUsersLastActivity(activityAnalyzer, activityAnalyzerTime, activityLogin, activityProfessional, activityUser) { + const usersActivity = []; + + for (const user of activityAnalyzer) { + // Does this user already exist in user activity array? + if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { + // User ID has already been added (seems it appears in more than one activity type!) + // Pick the most recent last activity date + logger.debug( + ` USER ACTIVITY ANALYZER: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` + ); + } else { + // User ID has not been added yet. Add it! + usersActivity.push({ + userSenseId: user.user.id, + userId: user.user.userId, + userDirectory: user.user.userDirectory, + userName: user.user.name, + lastUsed: user.lastUsed, + }); + } + } + + for (const user of activityAnalyzerTime) { + // Does this user already exist in user activity array? + if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { + // User ID has already been added (seems it appears in more than one activity type!) + // Pick the most recent last activity date + logger.debug( + ` USER ACTIVITY ANALYZER TIME: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` + ); + } else { + // User ID has not been added yet. Add it! + usersActivity.push({ + userSenseId: user.user.id, + userId: user.user.userId, + userDirectory: user.user.userDirectory, + userName: user.user.name, + lastUsed: user.latestActivity, + }); + } + } + + for (const user of activityLogin) { + // Does this user already exist in user activity array? + if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { + // User ID has already been added (seems it appears in more than one activity type!) + // Pick the most recent last activity date + logger.debug( + ` USER ACTIVITY LOGIN: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` + ); + } else { + // User ID has not been added yet. Add it! + usersActivity.push({ + userSenseId: user.user.id, + userId: user.user.userId, + userDirectory: user.user.userDirectory, + userName: user.user.name, + lastUsed: user.latestActivity, + }); + } + } + + for (const user of activityProfessional) { + // Does this user already exist in user activity array? + if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { + // User ID has already been added (seems it appears in more than one activity type!) + // Pick the most recent last activity date + logger.debug( + ` USER ACTIVITY PROFESSIONAL: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` + ); + } else { + // User ID has not been added yet. Add it! + usersActivity.push({ + userSenseId: user.user.id, + userId: user.user.userId, + userDirectory: user.user.userDirectory, + userName: user.user.name, + lastUsed: user.lastUsed, + }); + } + } + + for (const user of activityUser) { + // Does this user already exist in user activity array? + if (usersActivity.find((findUser) => findUser.userSenseId === user.user.id) !== undefined) { + // User ID has already been added (seems it appears in more than one activity type!) + // Pick the most recent last activity date + logger.debug( + ` USER ACTIVITY USER: User id ${user.user.id}, ${user.user.userDirectory}\\${user.user.userId} already exists in activity array. Will use entry with the most recent activity date.` + ); + } else { + // User ID has not been added yet. Add it! + usersActivity.push({ + userSenseId: user.user.id, + userId: user.user.userId, + userDirectory: user.user.userDirectory, + userName: user.user.name, + lastUsed: user.lastUsed, + }); } + } - logger.verbose(`USER ACTIVITY: Net list of user activity data consists of ${usersActivity.length} items.`); - resolve(usersActivity); - }); + logger.verbose(` USER ACTIVITY: Net list of user activity data consists of ${usersActivity.length} items.`); + return usersActivity; } diff --git a/src/lib/util/qseow/assert-options.js b/src/lib/util/qseow/assert-options.js index 1c3bb23..0e10c4c 100644 --- a/src/lib/util/qseow/assert-options.js +++ b/src/lib/util/qseow/assert-options.js @@ -1,6 +1,7 @@ -import path from 'node:path'; import { version as uuidVersion, validate as uuidValidate } from 'uuid'; + import { logger, execPath, verifyFileExists } from '../../../globals.js'; +import { getCertFilePaths } from '../qseow/cert.js'; export const qseowSharedParamAssertOptions = async (options) => { // Ensure that parameters common to all commands are valid @@ -18,8 +19,9 @@ export const qseowSharedParamAssertOptions = async (options) => { // If certificate authentication is used: certs and user dir/id must be present. if (options.authType === 'cert') { // Verify that certificate files exists (if specified) - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + // Get certificate paths + const { fileCert, fileCertKey, fileCertCA } = getCertFilePaths(options); const fileCertExists = await verifyFileExists(fileCert); if (fileCertExists === false) { @@ -36,6 +38,12 @@ export const qseowSharedParamAssertOptions = async (options) => { } else { logger.verbose(`Certificate key file ${fileCertKey} found`); } + + const fileCertCAExists = await verifyFileExists(fileCertCA); + if (fileCertCAExists === false) { + logger.error(`Missing certificate CA file ${fileCertCA}. Aborting`); + process.exit(1); + } } else if (options.authType === 'jwt') { // Verify that --auth-jwt parameter is specified if (options.authJwt === undefined || !options.authJwt) { @@ -124,22 +132,18 @@ export const masterItemDimDeleteAssertOptions = (options) => { } }; -// eslint-disable-next-line no-unused-vars export const masterItemGetAssertOptions = (options) => { // }; -// eslint-disable-next-line no-unused-vars export const getScriptAssertOptions = (options) => { // }; -// eslint-disable-next-line no-unused-vars export const getBookmarkAssertOptions = (options) => { // }; -// eslint-disable-next-line no-unused-vars export const getTaskAssertOptions = (options) => { // ---task-id and --task-tag only allowed for task tables, not trees if (options.taskId || options.taskTag) { @@ -150,7 +154,6 @@ export const getTaskAssertOptions = (options) => { // Verify all task IDs are valid uuids if (options.taskId) { - // eslint-disable-next-line no-restricted-syntax for (const taskId of options.taskId) { if (!uuidValidate(taskId)) { logger.error(`Invalid format of task ID parameter "${taskId}". Exiting.`); @@ -201,12 +204,10 @@ export const getTaskAssertOptions = (options) => { } }; -// eslint-disable-next-line no-unused-vars export const setTaskCustomPropertyAssertOptions = (options) => { // }; -// eslint-disable-next-line no-unused-vars export const taskImportAssertOptions = (options) => { // If --import-app is specified, the import file type must be Excel if (options.importApp && options.fileType !== 'excel') { @@ -231,7 +232,6 @@ export const taskImportAssertOptions = (options) => { } }; -// eslint-disable-next-line no-unused-vars export const appImportAssertOptions = (options) => { // }; @@ -241,7 +241,6 @@ export const appImportAssertExcelSheet = (options) => { // }; -// eslint-disable-next-line no-unused-vars export const appExportAssertOptions = async (options) => { // Verify output directory exists // const outputDir = mergeDirFilePath([options.outputDir]); @@ -293,12 +292,18 @@ export const variableDeleteAssertOptions = (options) => { } }; -// eslint-disable-next-line no-unused-vars export const getSessionsAssertOptions = (options) => { // }; -// eslint-disable-next-line no-unused-vars export const deleteSessionsAssertOptions = (options) => { // }; + +export const userActivityBucketsCustomPropertyAssertOptions = (options) => { + // Verify that custom property name only contains letters, numbers and underscores + if (!/^[a-zA-Z0-9_]+$/.test(options.customPropertyName)) { + logger.error(`Invalid custom property name "${options.customPropertyName}". Only letters, numbers and underscores are allowed.`); + process.exit(1); + } +}; diff --git a/src/lib/util/qseow/customproperties.js b/src/lib/util/qseow/customproperties.js index 4a0e473..bad667f 100644 --- a/src/lib/util/qseow/customproperties.js +++ b/src/lib/util/qseow/customproperties.js @@ -1,56 +1,36 @@ import axios from 'axios'; -import path from 'node:path'; -import { logger, execPath } from '../../../globals.js'; + +import { logger } from '../../../globals.js'; +import { catchLog } from '../../util/log.js'; import { setupQrsConnection } from './qrs.js'; -export function getCustomPropertiesFromQseow(options) { - return new Promise((resolve, _reject) => { - logger.verbose(`Getting custom properties from QSEoW...`); - - // Should cerrificates be used for authentication? - let axiosConfig; - if (options.authType === 'cert') { - // Make sure certificates exist - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - const fileCertCA = path.resolve(execPath, options.authRootCertFile); - - axiosConfig = setupQrsConnection(options, { - method: 'get', - fileCert, - fileCertKey, - fileCertCA, - path: '/qrs/custompropertydefinition/full', - }); - } else if (options.authType === 'jwt') { - axiosConfig = setupQrsConnection(options, { - method: 'get', - path: '/qrs/custompropertydefinition/full', - }); - } +export async function getCustomPropertiesFromQseow(options) { + logger.verbose(`Getting custom properties from QSEoW...`); + + try { + const axiosConfig = setupQrsConnection(options, { + method: 'get', + path: '/qrs/custompropertydefinition/full', + }); - axios - .request(axiosConfig) - .then((result) => { - if (result.status === 200) { - const response = JSON.parse(result.data); - logger.info(`Successfully retrieved ${response.length} custom properties from QSEoW`); - - // Yes, the tag exists - resolve(response); - } - resolve(false); - }) - .catch((err) => { - logger.error(`GET CUSTOM PROPERTIES FROM QSEoW: ${err}`); - }); - }); + const result = await axios.request(axiosConfig); + if (result.status === 200) { + const response = JSON.parse(result.data); + + // Yes, the custom property exists + return response; + } + return false; + } catch (err) { + catchLog('GET CUSTOM PROPERTIES FROM QSEoW', err); + return false; + } } export function getCustomPropertyIdByName(objectType, customPropertyName, cpExisting) { - return new Promise((resolve, _reject) => { - logger.debug(`Looking up ID for custom property named "${customPropertyName}" on object type "${objectType}"`); + logger.debug(`Looking up ID for custom property named "${customPropertyName}" on object type "${objectType}"`); + try { const cp = cpExisting.filter((item) => item.name === customPropertyName); if (cp.length === 1) { @@ -58,60 +38,26 @@ export function getCustomPropertyIdByName(objectType, customPropertyName, cpExis const correctObjectType = cp[0].objectTypes.find((item) => objectType.toLowerCase() === item.toLowerCase()); if (!correctObjectType) { logger.warn(`Custom property "${customPropertyName}" is not valid for task type "${objectType}".`); - resolve(false); + return false; } // Yes, the the custom property exists logger.verbose(`Successfully found ID ${cp[0].id} for custom property named "${customPropertyName}"`); - resolve(cp[0].id); + return cp[0].id; } else if (cp.length === 0) { logger.warn(`Custom property "${customPropertyName}" does not exist.`); - resolve(false); + return false; } - }); + } catch (err) { + catchLog('GET CUSTOM PROPERTY ID BY NAME', err); + return false; + } } -// function getCustomPropertyIdByName2(objectType, customPropertyName, options, fileCert, fileCertKey) { -// return new Promise((resolve, reject) => { -// logger.debug(`Looking up ID for custom property named "${customPropertyName}" on object type "${objectType}"`); - -// const axiosConfig = setupQrsConnection(options, { -// method: 'get', -// fileCert, -// fileCertKey, -// path: '/qrs/custompropertydefinition/full', -// queryParameters: [{ name: 'filter', value: encodeURI(`name eq '${customPropertyName}'`) }], -// }); - -// axios -// .request(axiosConfig) -// .then((result) => { -// if (result.status === 200 && result.data.length === 0) { -// logger.warn(`Custom property "${customPropertyName}" does not exist.`); -// resolve(false); -// } -// if (result.status === 200 && result.data.length === 1) { -// const correctObjectType = result.data[0].objectTypes.find((item) => objectType.toLowerCase() === item.toLowerCase()); -// if (!correctObjectType) { -// logger.warn(`Custom property "${customPropertyName}" is not valid for task type "${objectType}".`); -// resolve(false); -// } - -// // Yes, the the custom property exists -// logger.verbose(`Successfully found ID ${result.data[0].id} for custom property named "${customPropertyName}"`); -// resolve(result.data[0].id); -// } -// resolve(false); -// }) -// .catch((err) => { -// logger.error(`CUSTOM PROPERTY ID BY NAME: ${err}`); -// }); -// }); -// } export function getCustomPropertyDefinitionByName(objectType, customPropertyName, cpExisting) { - return new Promise((resolve, _reject) => { - logger.debug(`Looking up definition for custom property named "${customPropertyName}" on object type "${objectType}"`); + logger.debug(`Looking up definition for custom property named "${customPropertyName}" on object type "${objectType}"`); + try { const cp = cpExisting.filter((item) => item.name === customPropertyName); if (cp.length === 1) { @@ -119,62 +65,28 @@ export function getCustomPropertyDefinitionByName(objectType, customPropertyName const correctObjectType = cp[0].objectTypes.find((item) => objectType.toLowerCase() === item.toLowerCase()); if (!correctObjectType) { logger.warn(`Custom property "${customPropertyName}" is not valid for task type "${objectType}".`); - resolve(false); + return false; } // Yes, the the custom property exists logger.verbose(`Successfully found definition ${JSON.stringify(cp[0])} for custom property named "${customPropertyName}"`); - resolve(cp[0]); + return cp[0]; } else if (cp.length === 0) { logger.warn(`Custom property "${customPropertyName}" does not exist.`); - resolve(false); + return false; } - }); + } catch (err) { + catchLog('GET CUSTOM PROPERTY DEFINITION BY NAME', err); + return false; + } } -// function getCustomPropertyDefinitionByName2(objectType, customPropertyName, options, fileCert, fileCertKey) { -// return new Promise((resolve, reject) => { -// logger.debug(`Looking up definition for custom property named "${customPropertyName}" on object type "${objectType}"`); - -// const axiosConfig = setupQrsConnection(options, { -// method: 'get', -// fileCert, -// fileCertKey, -// path: '/qrs/custompropertydefinition/full', -// queryParameters: [{ name: 'filter', value: encodeURI(`name eq '${customPropertyName}'`) }], -// }); - -// axios -// .request(axiosConfig) -// .then((result) => { -// if (result.status === 200 && result.data.length === 0) { -// logger.warn(`Custom property "${customPropertyName}" does not exist.`); -// resolve(false); -// } -// if (result.status === 200 && result.data.length === 1) { -// const correctObjectType = result.data[0].objectTypes.find((item) => objectType.toLowerCase() === item.toLowerCase()); -// if (!correctObjectType) { -// logger.warn(`Custom property "${customPropertyName}" is not valid for task type "${objectType}".`); -// resolve(false); -// } - -// // Yes, the the custom property exists -// logger.verbose(`Successfully found definition ${result.data[0]} for custom property named "${customPropertyName}"`); -// resolve(result.data[0]); -// } -// resolve(false); -// }) -// .catch((err) => { -// logger.error(`CUSTOM PROPERTY ID BY NAME: ${err}`); -// }); -// }); -// } export function doesCustomPropertyValueExist(objectType, customPropertyName, customPropertyValue, cpExisting) { - return new Promise((resolve, _reject) => { - logger.debug( - `Checking if value "${customPropertyValue}" is valid for custom property "${customPropertyName}" on object type "${objectType}"` - ); + logger.debug( + `Checking if value "${customPropertyValue}" is valid for custom property "${customPropertyName}" on object type "${objectType}"` + ); + try { const cp = cpExisting.filter((item) => item.name === customPropertyName); if (cp.length === 1) { @@ -182,7 +94,7 @@ export function doesCustomPropertyValueExist(objectType, customPropertyName, cus const correctObjectType = cp[0].objectTypes.find((item) => objectType.toLowerCase() === item.toLowerCase()); if (!correctObjectType) { logger.warn(`Custom property "${customPropertyName}" is not valid for task type "${objectType}".`); - resolve(false); + return false; } // Check if value is valid for this custom property @@ -191,64 +103,84 @@ export function doesCustomPropertyValueExist(objectType, customPropertyName, cus logger.warn( `"${customPropertyValue}" is not a valid value for custom property "${customPropertyName}", for object type "${objectType}".` ); - resolve(false); + return false; } // Yes, the the custom property exists logger.verbose(`Successfully found ID ${cp[0].id} for custom property named "${customPropertyName}"`); - resolve(cp[0].id); + return cp[0].id; } else if (cp.length === 0) { logger.warn(`Custom property "${customPropertyName}" does not exist.`); - resolve(false); + return false; + } + } catch (err) { + catchLog('CUSTOM PROPERTY ID BY NAME', err); + return false; + } +} + +// Function to create a custom property +// customPropertyDefinition has properties: +// - objectTypes (array of strings). Types of objects this custom property is valid for. +// - name (string) +// - choiceValues (array of strings). Possible values for this custom property. +// - description (string) +// - values (array of strings). Values that are actually set for this custom property. +export async function createCustomProperty(options, customPropertyDefinition) { + logger.verbose(`Creating custom property "${customPropertyDefinition.name}"...`); + + try { + const axiosConfig = setupQrsConnection(options, { + method: 'post', + path: '/qrs/custompropertydefinition', + }); + + // Set payload + axiosConfig.data = customPropertyDefinition; + + logger.debug(`About to create custom property "${customPropertyDefinition.name}"`); + const result = await axios.request(axiosConfig); + + if (result.status === 201) { + logger.info(`Successfully created custom property "${customPropertyDefinition.name}"`); + return true; } - }); + + logger.error(`Failed to create custom property "${customPropertyDefinition.name}"`); + return false; + } catch (err) { + catchLog('CREATE CUSTOM PROPERTY', err); + } } -// function doesCustomPropertyValueExist2(objectType, customPropertyName, customPropertyValue, options, fileCert, fileCertKey) { -// return new Promise((resolve, reject) => { -// logger.debug( -// `Checking if value "${customPropertyValue}" is valid for custom property "${customPropertyName}" on object type "${objectType}"` -// ); - -// const axiosConfig = setupQrsConnection(options, { -// method: 'get', -// fileCert, -// fileCertKey, -// path: '/qrs/custompropertydefinition/full', -// queryParameters: [{ name: 'filter', value: encodeURI(`name eq '${customPropertyName}'`) }], -// }); - -// axios -// .request(axiosConfig) -// .then((result) => { -// if (result.status === 200 && result.data.length === 0) { -// logger.warn(`Custom property "${customPropertyName}" does not exist.`); -// resolve(false); -// } -// if (result.status === 200 && result.data.length === 1) { -// const correctObjectType = result.data[0].objectTypes.find((item) => objectType.toLowerCase() === item.toLowerCase()); -// if (!correctObjectType) { -// logger.warn(`Custom property "${customPropertyName}" is not valid for task type "${objectType}".`); -// resolve(false); -// } - -// // Check if value is valid for this custom property -// const valueExists = result.data[0].choiceValues.find((item) => item === customPropertyValue); -// if (!valueExists) { -// logger.warn( -// `"${customPropertyValue}" is not a valid value for custom property "${customPropertyName}", for object type "${objectType}".` -// ); -// resolve(false); -// } - -// // Yes, the the custom property exists -// logger.verbose(`Successfully found ID ${result.data[0].id} for custom property named "${customPropertyName}"`); -// resolve(result.data[0].id); -// } -// resolve(false); -// }) -// .catch((err) => { -// logger.error(`CUSTOM PROPERTY ID BY NAME: ${err}`); -// }); -// }); -// } +// Function to update a custom property +// +// Parameters: +// options: Command line options +// customPropertyDefinition: The new/updated custom property definition. Object with properties: +// - objectTypes (array of strings). Types of objects this custom property is valid for. + +export async function updateCustomProperty(options, customPropertyDefinition) { + logger.verbose(`Updating custom property "${customPropertyDefinition.name}"...`); + + try { + const axiosConfig = setupQrsConnection(options, { + method: 'put', + path: `/qrs/custompropertydefinition/${customPropertyDefinition.id}`, + body: customPropertyDefinition, + }); + + // Update custom property + const result = await axios.request(axiosConfig); + if (result.status === 200) { + logger.info(`Successfully updated custom property "${customPropertyDefinition.name}"`); + return true; + } + + logger.error(`Failed to update custom property "${customPropertyDefinition.name}"`); + return false; + } catch (err) { + catchLog('UPDATE CUSTOM PROPERTY', err); + return false; + } +}