Skip to content

Commit

Permalink
Merge branch 'staging' into ISSUE_5412
Browse files Browse the repository at this point in the history
  • Loading branch information
carmenfan committed Mar 7, 2025
2 parents 3213921 + 6b6e4e4 commit 9365537
Show file tree
Hide file tree
Showing 5 changed files with 416 additions and 3 deletions.
9 changes: 6 additions & 3 deletions .github/workflows/onPRClose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
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 }}"}'
96 changes: 96 additions & 0 deletions backend/src/scripts/utility/users/identifyInactiveUsers.js
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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,
};
82 changes: 82 additions & 0 deletions backend/src/scripts/utility/users/identifyOrphanedUsers.js
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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,
};
127 changes: 127 additions & 0 deletions backend/tests/v5/scripts/users/identifyInactiveUsers.test.js
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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

0 comments on commit 9365537

Please sign in to comment.