diff --git a/.github/workflows/onPRClose.yml b/.github/workflows/onPRClose.yml index a5e631f0f6a..d974a41f5c0 100644 --- a/.github/workflows/onPRClose.yml +++ b/.github/workflows/onPRClose.yml @@ -50,13 +50,16 @@ jobs: destroy-deployed-branch: name: Call Azure Destroy Pipeline runs-on: ubuntu-latest - needs: get-details steps: + - name: Set branch name to destroy + run: | + echo 'chartName='$( echo ${{ github.head_ref || github.ref_name }} | sed "s/_/-/" | awk '{print tolower($0)}' ) >> "$GITHUB_OUTPUT" + id: getChartName - name: Azure Pipelines Action - if: needs.get-details.outputs.issue-number + if: ${{ (steps.getChartName.outputs.chartName != 'staging') && (steps.getChartName.outputs.chartName != 'master') }} uses: Azure/pipelines@v1.2 with: azure-devops-project-url: https://dev.azure.com/3drepo/3drepo.io azure-pipeline-name: 'destroy' azure-devops-token: ${{ secrets.AZURE_DEVOPS_TOKEN }} - azure-pipeline-variables: '{"branchName": "issue-${{ needs.get-details.outputs.issue-number }}"}' + azure-pipeline-variables: '{"branchName": "${{ steps.getChartName.outputs.chartName }}"}' diff --git a/backend/src/scripts/utility/users/identifyInactiveUsers.js b/backend/src/scripts/utility/users/identifyInactiveUsers.js new file mode 100644 index 00000000000..77ba8f52fcf --- /dev/null +++ b/backend/src/scripts/utility/users/identifyInactiveUsers.js @@ -0,0 +1,96 @@ +/** + * Copyright (C) 2025 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const DayJS = require('dayjs'); +const Path = require('path'); +const { v5Path } = require('../../../interop'); +const FS = require('fs'); +const DBHandler = require('../../../v5/handler/db'); + +const { logger } = require(`${v5Path}/utils/logger`); +const { getUsersByQuery } = require(`${v5Path}/models/users`); +const { getLastLoginDate } = require(`${v5Path}/models/loginRecords`); + +const DEFAULT_OUT_FILE = 'inactiveUsers.csv'; + +const formatDate = (date) => (date ? DayJS(date).format('DD/MM/YYYY') : ''); + +const writeResultsToFile = (results, outFile) => new Promise((resolve) => { + logger.logInfo(`Writing results to ${outFile}`); + const writeStream = FS.createWriteStream(outFile); + writeStream.write('Username,First Name,Last Name,Email,Company,Last Login,Number of Teamspaces\n'); + results.forEach(({ user, firstName, lastName, email, company, lastLogin, teamspaceNumber }) => { + writeStream.write(`${user},${firstName},${lastName},${email},${company},${formatDate(lastLogin)},${teamspaceNumber}\n`); + }); + + writeStream.end(resolve); +}); + +const getFileEntry = async ({ user, roles, customData }) => { + const lastLogin = await getLastLoginDate(user); + const { firstName, lastName, email, billing } = customData; + const teamspaceNumber = roles.filter((role) => role.db !== 'admin').length; + + return { user, firstName, lastName, email, teamspaceNumber, company: billing?.billingInfo?.company ?? '', lastLogin: lastLogin ?? '' }; +}; + +const run = async (monthsOfInactivity, outFile) => { + const dateSinceLogin = new Date(); + dateSinceLogin.setMonth(dateSinceLogin.getMonth() - monthsOfInactivity); + + const activeUsernames = await DBHandler.distinct('internal', 'loginRecords', 'user', { loginTime: { $gt: dateSinceLogin } }); + + const projection = { + user: 1, + roles: 1, + 'customData.firstName': 1, + 'customData.lastName': 1, + 'customData.email': 1, + 'customData.billing.billingInfo.company': 1, + }; + + const inactiveUsers = await getUsersByQuery({ user: { $not: { $in: activeUsernames } }, roles: { role: 'user', db: 'admin' } }, projection); + const entries = await Promise.all(inactiveUsers.map(getFileEntry)); + + await writeResultsToFile(entries, outFile); +}; + +const genYargs = /* istanbul ignore next */ (yargs) => { + const commandName = Path.basename(__filename, Path.extname(__filename)); + + const argsSpec = (subYargs) => subYargs + .option('monthsOfInactivity', { + describe: 'Months passed since the user last logged in', + type: 'number', + }) + .option('outFile', { + describe: 'Name of output file', + type: 'string', + default: DEFAULT_OUT_FILE, + }); + + return yargs.command( + commandName, + 'Identify users that have not logged in a specified number of months', + argsSpec, + ({ monthsOfInactivity, outFile }) => run(monthsOfInactivity, outFile)); +}; + +module.exports = { + run, + genYargs, +}; diff --git a/backend/src/scripts/utility/users/identifyOrphanedUsers.js b/backend/src/scripts/utility/users/identifyOrphanedUsers.js new file mode 100644 index 00000000000..cac7558098b --- /dev/null +++ b/backend/src/scripts/utility/users/identifyOrphanedUsers.js @@ -0,0 +1,82 @@ +/** + * Copyright (C) 2025 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const DayJS = require('dayjs'); +const Path = require('path'); +const { v5Path } = require('../../../interop'); +const FS = require('fs'); + +const { logger } = require(`${v5Path}/utils/logger`); +const { getUsersByQuery } = require(`${v5Path}/models/users`); +const { getLastLoginDate } = require(`${v5Path}/models/loginRecords`); + +const DEFAULT_OUT_FILE = 'orphanedUsers.csv'; + +const formatDate = (date) => (date ? DayJS(date).format('DD/MM/YYYY') : ''); + +const writeResultsToFile = (results, outFile) => new Promise((resolve) => { + logger.logInfo(`Writing results to ${outFile}`); + const writeStream = FS.createWriteStream(outFile); + writeStream.write('Username,First Name,Last Name,Email,Company,Last Login\n'); + results.forEach(({ user, firstName, lastName, email, company, lastLogin }) => { + writeStream.write(`${user},${firstName},${lastName},${email},${company},${formatDate(lastLogin)}\n`); + }); + + writeStream.end(resolve); +}); + +const getFileEntry = async ({ user, customData }) => { + const lastLogin = await getLastLoginDate(user); + const { firstName, lastName, email, billing } = customData; + + return { user, firstName, lastName, email, company: billing?.billingInfo?.company ?? '', lastLogin: lastLogin ?? '' }; +}; + +const run = async (outFile) => { + const projection = { + user: 1, + 'customData.firstName': 1, + 'customData.lastName': 1, + 'customData.email': 1, + 'customData.billing.billingInfo.company': 1, + }; + + const orphanedUsers = await getUsersByQuery({ roles: [{ role: 'user', db: 'admin' }] }, projection); + const entries = await Promise.all(orphanedUsers.map(getFileEntry)); + + await writeResultsToFile(entries, outFile); +}; + +const genYargs = /* istanbul ignore next */ (yargs) => { + const commandName = Path.basename(__filename, Path.extname(__filename)); + + const argsSpec = (subYargs) => subYargs.option('outFile', { + describe: 'Name of output file', + type: 'string', + default: DEFAULT_OUT_FILE, + }); + + return yargs.command(commandName, + 'Identify users that do not belong to any teamspace', + argsSpec, + ({ outFile }) => run(outFile)); +}; + +module.exports = { + run, + genYargs, +}; diff --git a/backend/tests/v5/scripts/users/identifyInactiveUsers.test.js b/backend/tests/v5/scripts/users/identifyInactiveUsers.test.js new file mode 100644 index 00000000000..03a20cd9c6f --- /dev/null +++ b/backend/tests/v5/scripts/users/identifyInactiveUsers.test.js @@ -0,0 +1,127 @@ +/** + * Copyright (C) 2025 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { + determineTestGroup, + db: { reset: resetDB, createUser, createTeamspace, addLoginRecords }, + generateRandomString, + generateUserCredentials, + fileExists, + generateRandomNumber, +} = require('../../helper/services'); + +const { times } = require('lodash'); +const { readFileSync } = require('fs'); +const DayJS = require('dayjs'); + +const { src, utilScripts, tmpDir } = require('../../helper/path'); + +const DBHandler = require(`${src}/handler/db`); +const { ADMIN_DB } = require(`${src}/handler/db.constants`); +const { USERS_COL } = require(`${src}/models/users.constants`); + +const IdentifyInactiveUsers = require(`${utilScripts}/users/identifyInactiveUsers`); +const { disconnect } = require(`${src}/handler/db`); + +const generateLoginRecord = (user) => { + const loginTime = new Date(); + loginTime.setMonth(loginTime.getMonth() - Math.round(generateRandomNumber(1, 12))); + return { _id: generateRandomString(), user, loginTime }; +}; + +const setupData = async () => { + const teamspace = generateRandomString(); + const teamspace2 = generateRandomString(); + const users = times(20, () => generateUserCredentials()); + delete users[1].basicData.billing.billingInfo.company; + + await Promise.all([ + createTeamspace(teamspace, [], undefined, false), + createTeamspace(teamspace2, [], undefined, false), + ]); + + await Promise.all(users.map(async (user, index) => { + await createUser(user, [teamspace, teamspace2]); + + if (index % 2 === 0) { + const loginRecords = times(5, () => generateLoginRecord(user.user)); + await addLoginRecords(loginRecords); + + const [lastLoginRecord] = loginRecords.sort((a, b) => b.loginTime - a.loginTime); + // eslint-disable-next-line no-param-reassign + user.lastLogin = lastLoginRecord.loginTime; + } + + if (index % 10 === 0) { + await DBHandler.updateOne(ADMIN_DB, USERS_COL, { user: user.user }, { $set: { roles: [] } }); + // eslint-disable-next-line no-param-reassign + user.invalidUser = true; + } + })); + + return users; +}; + +const runTest = () => { + describe('Identify inactive users', () => { + let users; + + beforeAll(async () => { + await resetDB(); + users = await setupData(); + }); + + const formatDate = (date) => (date ? DayJS(date).format('DD/MM/YYYY') : ''); + + test('should provide a list of users that have not logged in for a specified number of months', async () => { + const outFile = `${tmpDir}/${generateRandomString()}.csv`; + const monthsOfInactivity = Math.round(generateRandomNumber(1, 5)); + + await IdentifyInactiveUsers.run(monthsOfInactivity, outFile); + + expect(fileExists(outFile)).toBeTruthy(); + + // first line is csv titles, last line is always empty + const content = readFileSync(outFile).toString().split('\n').slice(1, -1); + const res = content.map((str) => { + const [user, firstName, lastName, email, company, lastLogin, teamspaceNumber] = str.split(','); + return { user, firstName, lastName, email, company, lastLogin, teamspaceNumber }; + }); + + const thresholdDate = new Date(); + thresholdDate.setMonth(thresholdDate.getMonth() - monthsOfInactivity); + + const expectedResult = users.flatMap(({ user, invalidUser, basicData, lastLogin }) => { + if (!invalidUser && (!lastLogin || lastLogin < thresholdDate)) { + const { email, firstName, lastName, billing } = basicData; + return { user, email, firstName, lastName, company: billing.billingInfo.company ?? '', lastLogin: formatDate(lastLogin), teamspaceNumber: '2' }; + } + + return []; + }); + + expect(res.length).toBe(expectedResult.length); + expect(res.sort((a, b) => a.user.localeCompare(b.user))) + .toEqual(expectedResult.sort((a, b) => a.user.localeCompare(b.user))); + }); + }); +}; + +describe(determineTestGroup(__filename), () => { + runTest(); + afterAll(disconnect); +}); diff --git a/backend/tests/v5/scripts/users/identifyOrphanedUsers.test.js b/backend/tests/v5/scripts/users/identifyOrphanedUsers.test.js new file mode 100644 index 00000000000..9433ba10289 --- /dev/null +++ b/backend/tests/v5/scripts/users/identifyOrphanedUsers.test.js @@ -0,0 +1,105 @@ +/** + * Copyright (C) 2025 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { + determineTestGroup, + db: { reset: resetDB, createUser, createTeamspace, addLoginRecords }, + generateRandomString, + generateUserCredentials, + fileExists, + generateRandomNumber, +} = require('../../helper/services'); + +const { times } = require('lodash'); +const { readFileSync } = require('fs'); +const DayJS = require('dayjs'); + +const { src, utilScripts, tmpDir } = require('../../helper/path'); + +const IdentifyOrphanedUsers = require(`${utilScripts}/users/identifyOrphanedUsers`); +const { disconnect } = require(`${src}/handler/db`); + +const generateLoginRecord = (user) => { + const loginTime = new Date(); + loginTime.setMonth(loginTime.getMonth() - Math.round(generateRandomNumber(1, 12))); + return { _id: generateRandomString(), user, loginTime }; +}; + +const setupData = async () => { + const teamspace = generateRandomString(); + const normalUsers = times(10, () => generateUserCredentials()); + const orphanedUsers = times(10, () => generateUserCredentials()); + delete orphanedUsers[0].basicData.billing.billingInfo.company; + + await createTeamspace(teamspace, [], undefined, false); + + await Promise.all(normalUsers.map((user) => createUser(user, [teamspace]))); + + await Promise.all(orphanedUsers.map(async (user, index) => { + await createUser(user, []); + + if (index % 2 === 0) { + const loginRecords = times(5, () => generateLoginRecord(user.user)); + await addLoginRecords(loginRecords); + + const [lastLoginRecord] = loginRecords.sort((a, b) => b.loginTime - a.loginTime); + // eslint-disable-next-line no-param-reassign + user.lastLogin = lastLoginRecord.loginTime; + } + })); + + return orphanedUsers; +}; + +const runTest = () => { + describe('Identify orphaned users', () => { + let orphanedUsers; + + beforeAll(async () => { + await resetDB(); + orphanedUsers = await setupData(); + }); + + const formatDate = (date) => (date ? DayJS(date).format('DD/MM/YYYY') : ''); + + test('should provide a list of users with no access to teamspace', async () => { + const outFile = `${tmpDir}/${generateRandomString()}.csv`; + await IdentifyOrphanedUsers.run(outFile); + expect(fileExists(outFile)).toBeTruthy(); + + // first line is csv titles, last line is always empty + const content = readFileSync(outFile).toString().split('\n').slice(1, -1); + const res = content.map((str) => { + const [user, firstName, lastName, email, company, lastLogin] = str.split(','); + return { user, firstName, lastName, email, company, lastLogin }; + }); + + const expectedResult = orphanedUsers.map(({ user, basicData, lastLogin }) => { + const { email, firstName, lastName, billing } = basicData; + return { user, email, firstName, lastName, company: billing.billingInfo.company ?? '', lastLogin: formatDate(lastLogin) }; + }); + + expect(res.length).toBe(expectedResult.length); + expect(res).toEqual(expectedResult); + }); + }); +}; + +describe(determineTestGroup(__filename), () => { + runTest(); + afterAll(disconnect); +});