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);
+});