diff --git a/controllers/users.js b/controllers/users.js index 82c197ced..eefa62c6e 100644 --- a/controllers/users.js +++ b/controllers/users.js @@ -955,6 +955,16 @@ const getIdentityStats = async (req, res) => { }); }; +const updateUsernames = async (req, res) => { + try { + const response = await userQuery.updateUsersWithNewUsernames(); + return res.status(200).json(response); + } catch (error) { + logger.error("Error in username update script", error); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + module.exports = { verifyUser, generateChaincode, @@ -986,4 +996,5 @@ module.exports = { usersPatchHandler, isDeveloper, getIdentityStats, + updateUsernames, }; diff --git a/models/applications.ts b/models/applications.ts index e6595634e..995f4c2e3 100644 --- a/models/applications.ts +++ b/models/applications.ts @@ -91,7 +91,10 @@ const getApplicationsBasedOnStatus = async (status: string, limit: number, lastD const getUserApplications = async (userId: string) => { try { const applicationsResult = []; - const applications = await ApplicationsModel.where("userId", "==", userId).get(); + const applications = await ApplicationsModel.where("userId", "==", userId) + .orderBy("createdAt", "desc") + .limit(1) + .get(); applications.forEach((application) => { applicationsResult.push({ @@ -99,6 +102,7 @@ const getUserApplications = async (userId: string) => { ...application.data(), }); }); + return applicationsResult; } catch (err) { logger.log("error in getting user intro", err); diff --git a/models/users.js b/models/users.js index 82a0b2c45..3d14c7636 100644 --- a/models/users.js +++ b/models/users.js @@ -913,6 +913,136 @@ const getNonNickNameSyncedUsers = async () => { } }; +const updateUsernamesInBatch = async (usersData) => { + const batch = firestore.batch(); + const usersBatch = []; + const summary = { + totalUpdatedUsernames: 0, + totalOperationsFailed: 0, + failedUserDetails: [], + }; + + usersData.forEach((user) => { + const updateUserData = { ...user, username: user.username }; + batch.update(userModel.doc(user.id), updateUserData); + usersBatch.push(user.id); + }); + + try { + await batch.commit(); + summary.totalUpdatedUsernames += usersData.length; + return { ...summary }; + } catch (err) { + logger.error("Firebase batch Operation Failed!"); + summary.totalOperationsFailed += usersData.length; + summary.failedUserDetails = [...usersBatch]; + return { ...summary }; + } +}; + +const sanitizeString = (str) => { + if (!str) return ""; + return str.replace(/[^a-zA-Z0-9-]/g, "-"); +}; + +const formatUsername = (firstName, lastName, suffix) => { + const actualFirstName = firstName.split(" ")[0].toLowerCase(); + const sanitizedFirstName = sanitizeString(actualFirstName); + const sanitizedLastName = sanitizeString(lastName).toLowerCase(); + + const baseUsername = `${sanitizedFirstName}-${sanitizedLastName}`; + const fullUsername = `${baseUsername}-${suffix}`; + + return fullUsername.length <= 32 + ? fullUsername + : `${baseUsername.slice(0, 32 - suffix.toString().length - 1)}-${suffix}`; +}; + +const updateUsersWithNewUsernames = async () => { + try { + const snapshot = await userModel.get(); + + const nonMemberUsers = snapshot.docs.filter((doc) => { + const userData = doc.data(); + const roles = userData.roles; + + return !(roles?.member === true || roles?.super_user === true || userData.incompleteUserDetails === true); + }); + + const summary = { + totalUsers: nonMemberUsers.length, + totalUpdatedUsernames: 0, + totalOperationsFailed: 0, + failedUserDetails: [], + }; + + if (nonMemberUsers.length === 0) { + return summary; + } + + const usersToUpdate = []; + const nameToUsersMap = new Map(); + + nonMemberUsers.forEach((userDoc) => { + const userData = userDoc.data(); + const id = userDoc.id; + + const firstName = userData.first_name?.split(" ")[0]?.toLowerCase(); + const lastName = userData.last_name?.toLowerCase(); + + if (!firstName || !lastName) { + return; + } + + const fullName = `${firstName}-${lastName}`; + if (!nameToUsersMap.has(fullName)) { + nameToUsersMap.set(fullName, []); + } + + nameToUsersMap.get(fullName).push({ id, userData, createdAt: userData.created_at }); + }); + + for (const [, usersWithSameName] of nameToUsersMap.entries()) { + usersWithSameName.sort((a, b) => a.createdAt - b.createdAt); + + usersWithSameName.forEach((user, index) => { + const suffix = index + 1; + const formattedUsername = formatUsername(user.userData.first_name, user.userData.last_name, suffix); + + if (user.userData.username !== formattedUsername) { + usersToUpdate.push({ ...user.userData, id: user.id, username: formattedUsername }); + } + }); + } + + const userChunks = chunks(usersToUpdate, DOCUMENT_WRITE_SIZE); + + const updatedUsersPromises = await Promise.all( + userChunks.map(async (users) => { + const res = await updateUsernamesInBatch(users); + return res; + }) + ); + + updatedUsersPromises.forEach((res) => { + summary.totalUpdatedUsernames += res.totalUpdatedUsernames; + summary.totalOperationsFailed += res.totalOperationsFailed; + if (res.failedUserDetails.length > 0) { + summary.failedUserDetails.push(...res.failedUserDetails); + } + }); + + if (summary.totalOperationsFailed === summary.totalUsers) { + throw new Error("INTERNAL_SERVER_ERROR"); + } + + return summary; + } catch (error) { + logger.error(`Error in updating usernames: ${error}`); + throw error; + } +}; + module.exports = { addOrUpdate, fetchPaginatedUsers, @@ -942,4 +1072,5 @@ module.exports = { fetchUsersListForMultipleValues, fetchUserForKeyValue, getNonNickNameSyncedUsers, + updateUsersWithNewUsernames, }; diff --git a/routes/users.js b/routes/users.js index 9b29099d7..32c906c2d 100644 --- a/routes/users.js +++ b/routes/users.js @@ -67,3 +67,5 @@ router.patch("/rejectDiff", authenticate, authorizeRoles([SUPERUSER]), users.rej router.patch("/:userId", authenticate, authorizeRoles([SUPERUSER]), users.updateUser); router.get("/suggestedUsers/:skillId", authenticate, authorizeRoles([SUPERUSER]), users.getSuggestedUsers); module.exports = router; +router.post("/batch-username-update", authenticate, authorizeRoles([SUPERUSER]), users.updateUsernames); +module.exports = router; diff --git a/test/integration/users.test.js b/test/integration/users.test.js index 92acc8325..6e891e23e 100644 --- a/test/integration/users.test.js +++ b/test/integration/users.test.js @@ -2423,4 +2423,38 @@ describe("Users", function () { }); }); }); + + describe("POST USERS MIGRATION", function () { + it("should run the migration and update usernames successfully", async function () { + const res = await chai + .request(app) + .post("/users/batch-username-update") + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .send(); + + expect(res).to.have.status(200); + }); + + it("should not update usernames for super_user or member", async function () { + const res = await chai + .request(app) + .post("/users/batch-username-update") + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .send(); + + expect(res).to.have.status(200); + const affectedUsers = res.body.totalUpdatedUsernames; + expect(affectedUsers).to.equal(0); + }); + + it("should return 401 for unauthorized user attempting migration", async function () { + const res = await chai + .request(app) + .post("/users/batch-username-update") + .set("cookie", `${cookieName}=${jwt}`) + .send(); + + expect(res).to.have.status(401); + }); + }); }); diff --git a/test/unit/models/application.test.ts b/test/unit/models/application.test.ts index 0c9395299..96165c41f 100644 --- a/test/unit/models/application.test.ts +++ b/test/unit/models/application.test.ts @@ -48,7 +48,7 @@ describe("applications", function () { }); describe("getUserApplications", function () { - it("should return all the user applications", async function () { + it("should return users most recent application", async function () { const applications = await ApplicationModel.getUserApplications("kfasdjfkdlfjkasdjflsdjfk"); expect(applications).to.be.a("array"); expect(applications.length).to.be.equal(1);