Skip to content

Commit 9365537

Browse files
committed
Merge branch 'staging' into ISSUE_5412
2 parents 3213921 + 6b6e4e4 commit 9365537

File tree

5 files changed

+416
-3
lines changed

5 files changed

+416
-3
lines changed

.github/workflows/onPRClose.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,16 @@ jobs:
5050
destroy-deployed-branch:
5151
name: Call Azure Destroy Pipeline
5252
runs-on: ubuntu-latest
53-
needs: get-details
5453
steps:
54+
- name: Set branch name to destroy
55+
run: |
56+
echo 'chartName='$( echo ${{ github.head_ref || github.ref_name }} | sed "s/_/-/" | awk '{print tolower($0)}' ) >> "$GITHUB_OUTPUT"
57+
id: getChartName
5558
- name: Azure Pipelines Action
56-
if: needs.get-details.outputs.issue-number
59+
if: ${{ (steps.getChartName.outputs.chartName != 'staging') && (steps.getChartName.outputs.chartName != 'master') }}
5760
uses: Azure/[email protected]
5861
with:
5962
azure-devops-project-url: https://dev.azure.com/3drepo/3drepo.io
6063
azure-pipeline-name: 'destroy'
6164
azure-devops-token: ${{ secrets.AZURE_DEVOPS_TOKEN }}
62-
azure-pipeline-variables: '{"branchName": "issue-${{ needs.get-details.outputs.issue-number }}"}'
65+
azure-pipeline-variables: '{"branchName": "${{ steps.getChartName.outputs.chartName }}"}'
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Copyright (C) 2025 3D Repo Ltd
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Affero General Public License as
6+
* published by the Free Software Foundation, either version 3 of the
7+
* License, or (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
const DayJS = require('dayjs');
19+
const Path = require('path');
20+
const { v5Path } = require('../../../interop');
21+
const FS = require('fs');
22+
const DBHandler = require('../../../v5/handler/db');
23+
24+
const { logger } = require(`${v5Path}/utils/logger`);
25+
const { getUsersByQuery } = require(`${v5Path}/models/users`);
26+
const { getLastLoginDate } = require(`${v5Path}/models/loginRecords`);
27+
28+
const DEFAULT_OUT_FILE = 'inactiveUsers.csv';
29+
30+
const formatDate = (date) => (date ? DayJS(date).format('DD/MM/YYYY') : '');
31+
32+
const writeResultsToFile = (results, outFile) => new Promise((resolve) => {
33+
logger.logInfo(`Writing results to ${outFile}`);
34+
const writeStream = FS.createWriteStream(outFile);
35+
writeStream.write('Username,First Name,Last Name,Email,Company,Last Login,Number of Teamspaces\n');
36+
results.forEach(({ user, firstName, lastName, email, company, lastLogin, teamspaceNumber }) => {
37+
writeStream.write(`${user},${firstName},${lastName},${email},${company},${formatDate(lastLogin)},${teamspaceNumber}\n`);
38+
});
39+
40+
writeStream.end(resolve);
41+
});
42+
43+
const getFileEntry = async ({ user, roles, customData }) => {
44+
const lastLogin = await getLastLoginDate(user);
45+
const { firstName, lastName, email, billing } = customData;
46+
const teamspaceNumber = roles.filter((role) => role.db !== 'admin').length;
47+
48+
return { user, firstName, lastName, email, teamspaceNumber, company: billing?.billingInfo?.company ?? '', lastLogin: lastLogin ?? '' };
49+
};
50+
51+
const run = async (monthsOfInactivity, outFile) => {
52+
const dateSinceLogin = new Date();
53+
dateSinceLogin.setMonth(dateSinceLogin.getMonth() - monthsOfInactivity);
54+
55+
const activeUsernames = await DBHandler.distinct('internal', 'loginRecords', 'user', { loginTime: { $gt: dateSinceLogin } });
56+
57+
const projection = {
58+
user: 1,
59+
roles: 1,
60+
'customData.firstName': 1,
61+
'customData.lastName': 1,
62+
'customData.email': 1,
63+
'customData.billing.billingInfo.company': 1,
64+
};
65+
66+
const inactiveUsers = await getUsersByQuery({ user: { $not: { $in: activeUsernames } }, roles: { role: 'user', db: 'admin' } }, projection);
67+
const entries = await Promise.all(inactiveUsers.map(getFileEntry));
68+
69+
await writeResultsToFile(entries, outFile);
70+
};
71+
72+
const genYargs = /* istanbul ignore next */ (yargs) => {
73+
const commandName = Path.basename(__filename, Path.extname(__filename));
74+
75+
const argsSpec = (subYargs) => subYargs
76+
.option('monthsOfInactivity', {
77+
describe: 'Months passed since the user last logged in',
78+
type: 'number',
79+
})
80+
.option('outFile', {
81+
describe: 'Name of output file',
82+
type: 'string',
83+
default: DEFAULT_OUT_FILE,
84+
});
85+
86+
return yargs.command(
87+
commandName,
88+
'Identify users that have not logged in a specified number of months',
89+
argsSpec,
90+
({ monthsOfInactivity, outFile }) => run(monthsOfInactivity, outFile));
91+
};
92+
93+
module.exports = {
94+
run,
95+
genYargs,
96+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Copyright (C) 2025 3D Repo Ltd
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Affero General Public License as
6+
* published by the Free Software Foundation, either version 3 of the
7+
* License, or (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
const DayJS = require('dayjs');
19+
const Path = require('path');
20+
const { v5Path } = require('../../../interop');
21+
const FS = require('fs');
22+
23+
const { logger } = require(`${v5Path}/utils/logger`);
24+
const { getUsersByQuery } = require(`${v5Path}/models/users`);
25+
const { getLastLoginDate } = require(`${v5Path}/models/loginRecords`);
26+
27+
const DEFAULT_OUT_FILE = 'orphanedUsers.csv';
28+
29+
const formatDate = (date) => (date ? DayJS(date).format('DD/MM/YYYY') : '');
30+
31+
const writeResultsToFile = (results, outFile) => new Promise((resolve) => {
32+
logger.logInfo(`Writing results to ${outFile}`);
33+
const writeStream = FS.createWriteStream(outFile);
34+
writeStream.write('Username,First Name,Last Name,Email,Company,Last Login\n');
35+
results.forEach(({ user, firstName, lastName, email, company, lastLogin }) => {
36+
writeStream.write(`${user},${firstName},${lastName},${email},${company},${formatDate(lastLogin)}\n`);
37+
});
38+
39+
writeStream.end(resolve);
40+
});
41+
42+
const getFileEntry = async ({ user, customData }) => {
43+
const lastLogin = await getLastLoginDate(user);
44+
const { firstName, lastName, email, billing } = customData;
45+
46+
return { user, firstName, lastName, email, company: billing?.billingInfo?.company ?? '', lastLogin: lastLogin ?? '' };
47+
};
48+
49+
const run = async (outFile) => {
50+
const projection = {
51+
user: 1,
52+
'customData.firstName': 1,
53+
'customData.lastName': 1,
54+
'customData.email': 1,
55+
'customData.billing.billingInfo.company': 1,
56+
};
57+
58+
const orphanedUsers = await getUsersByQuery({ roles: [{ role: 'user', db: 'admin' }] }, projection);
59+
const entries = await Promise.all(orphanedUsers.map(getFileEntry));
60+
61+
await writeResultsToFile(entries, outFile);
62+
};
63+
64+
const genYargs = /* istanbul ignore next */ (yargs) => {
65+
const commandName = Path.basename(__filename, Path.extname(__filename));
66+
67+
const argsSpec = (subYargs) => subYargs.option('outFile', {
68+
describe: 'Name of output file',
69+
type: 'string',
70+
default: DEFAULT_OUT_FILE,
71+
});
72+
73+
return yargs.command(commandName,
74+
'Identify users that do not belong to any teamspace',
75+
argsSpec,
76+
({ outFile }) => run(outFile));
77+
};
78+
79+
module.exports = {
80+
run,
81+
genYargs,
82+
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Copyright (C) 2025 3D Repo Ltd
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Affero General Public License as
6+
* published by the Free Software Foundation, either version 3 of the
7+
* License, or (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
const {
19+
determineTestGroup,
20+
db: { reset: resetDB, createUser, createTeamspace, addLoginRecords },
21+
generateRandomString,
22+
generateUserCredentials,
23+
fileExists,
24+
generateRandomNumber,
25+
} = require('../../helper/services');
26+
27+
const { times } = require('lodash');
28+
const { readFileSync } = require('fs');
29+
const DayJS = require('dayjs');
30+
31+
const { src, utilScripts, tmpDir } = require('../../helper/path');
32+
33+
const DBHandler = require(`${src}/handler/db`);
34+
const { ADMIN_DB } = require(`${src}/handler/db.constants`);
35+
const { USERS_COL } = require(`${src}/models/users.constants`);
36+
37+
const IdentifyInactiveUsers = require(`${utilScripts}/users/identifyInactiveUsers`);
38+
const { disconnect } = require(`${src}/handler/db`);
39+
40+
const generateLoginRecord = (user) => {
41+
const loginTime = new Date();
42+
loginTime.setMonth(loginTime.getMonth() - Math.round(generateRandomNumber(1, 12)));
43+
return { _id: generateRandomString(), user, loginTime };
44+
};
45+
46+
const setupData = async () => {
47+
const teamspace = generateRandomString();
48+
const teamspace2 = generateRandomString();
49+
const users = times(20, () => generateUserCredentials());
50+
delete users[1].basicData.billing.billingInfo.company;
51+
52+
await Promise.all([
53+
createTeamspace(teamspace, [], undefined, false),
54+
createTeamspace(teamspace2, [], undefined, false),
55+
]);
56+
57+
await Promise.all(users.map(async (user, index) => {
58+
await createUser(user, [teamspace, teamspace2]);
59+
60+
if (index % 2 === 0) {
61+
const loginRecords = times(5, () => generateLoginRecord(user.user));
62+
await addLoginRecords(loginRecords);
63+
64+
const [lastLoginRecord] = loginRecords.sort((a, b) => b.loginTime - a.loginTime);
65+
// eslint-disable-next-line no-param-reassign
66+
user.lastLogin = lastLoginRecord.loginTime;
67+
}
68+
69+
if (index % 10 === 0) {
70+
await DBHandler.updateOne(ADMIN_DB, USERS_COL, { user: user.user }, { $set: { roles: [] } });
71+
// eslint-disable-next-line no-param-reassign
72+
user.invalidUser = true;
73+
}
74+
}));
75+
76+
return users;
77+
};
78+
79+
const runTest = () => {
80+
describe('Identify inactive users', () => {
81+
let users;
82+
83+
beforeAll(async () => {
84+
await resetDB();
85+
users = await setupData();
86+
});
87+
88+
const formatDate = (date) => (date ? DayJS(date).format('DD/MM/YYYY') : '');
89+
90+
test('should provide a list of users that have not logged in for a specified number of months', async () => {
91+
const outFile = `${tmpDir}/${generateRandomString()}.csv`;
92+
const monthsOfInactivity = Math.round(generateRandomNumber(1, 5));
93+
94+
await IdentifyInactiveUsers.run(monthsOfInactivity, outFile);
95+
96+
expect(fileExists(outFile)).toBeTruthy();
97+
98+
// first line is csv titles, last line is always empty
99+
const content = readFileSync(outFile).toString().split('\n').slice(1, -1);
100+
const res = content.map((str) => {
101+
const [user, firstName, lastName, email, company, lastLogin, teamspaceNumber] = str.split(',');
102+
return { user, firstName, lastName, email, company, lastLogin, teamspaceNumber };
103+
});
104+
105+
const thresholdDate = new Date();
106+
thresholdDate.setMonth(thresholdDate.getMonth() - monthsOfInactivity);
107+
108+
const expectedResult = users.flatMap(({ user, invalidUser, basicData, lastLogin }) => {
109+
if (!invalidUser && (!lastLogin || lastLogin < thresholdDate)) {
110+
const { email, firstName, lastName, billing } = basicData;
111+
return { user, email, firstName, lastName, company: billing.billingInfo.company ?? '', lastLogin: formatDate(lastLogin), teamspaceNumber: '2' };
112+
}
113+
114+
return [];
115+
});
116+
117+
expect(res.length).toBe(expectedResult.length);
118+
expect(res.sort((a, b) => a.user.localeCompare(b.user)))
119+
.toEqual(expectedResult.sort((a, b) => a.user.localeCompare(b.user)));
120+
});
121+
});
122+
};
123+
124+
describe(determineTestGroup(__filename), () => {
125+
runTest();
126+
afterAll(disconnect);
127+
});

0 commit comments

Comments
 (0)