From 860f1295e589753cc8ef24a620f260ff657a9099 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 22 Dec 2024 21:19:27 +0000 Subject: [PATCH] ENCOA-283 or ENCOA-282, I don't know someone deleted the issue --- src/components/ImportSummaries/Classroom.tsx | 13 +- .../Imports/StudentClassroomTransfer.tsx | 115 +++++++++---- src/components/Tables/UserTable.tsx | 18 ++- src/interfaces/IUserImport.ts | 1 + src/pages/api/groups/controller.ts | 72 ++++++++- src/pages/api/users/controller.ts | 151 ++++++++++++++++-- 6 files changed, 314 insertions(+), 56 deletions(-) diff --git a/src/components/ImportSummaries/Classroom.tsx b/src/components/ImportSummaries/Classroom.tsx index 704f7d2a..d0386412 100644 --- a/src/components/ImportSummaries/Classroom.tsx +++ b/src/components/ImportSummaries/Classroom.tsx @@ -195,6 +195,13 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta )} + {state.alreadyInClass && state.alreadyInClass.length > 0 && ( +
  • +
    + {state.alreadyInClass.length} users that are already assigned to the classroom +
    +
  • + )} {errorCount > 0 && (
  • @@ -221,7 +228,7 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta - setShowNotFoundModal(false)}> + setShowNotFoundModal(false)} maxWidth="max-w-[85%]"> <>
    @@ -231,7 +238,7 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta - setShowOtherEntityModal(false)}> + setShowOtherEntityModal(false)} maxWidth="max-w-[85%]"> <>
    @@ -241,7 +248,7 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta - setShowAlreadyInClassModal(false)}> + setShowAlreadyInClassModal(false)} maxWidth="max-w-[85%]"> <>
    diff --git a/src/components/Imports/StudentClassroomTransfer.tsx b/src/components/Imports/StudentClassroomTransfer.tsx index ed31131d..bc6f2dc6 100644 --- a/src/components/Imports/StudentClassroomTransfer.tsx +++ b/src/components/Imports/StudentClassroomTransfer.tsx @@ -161,10 +161,9 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole readXlsxFile( file.content, { schema, ignoreEmptyRows: false }) .then((data) => { - setClassroomTransferState((prev) => ({...prev, parsedExcel: data})) - console.log(data); + setClassroomTransferState((prev) => ({ ...prev, parsedExcel: data })) }); - + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [filesContent]); @@ -236,7 +235,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole } as UserImport; }) .filter((item): item is UserImport => item !== undefined); - console.log(infos); // On import reset state except excel parsing setClassroomTransferState((prev) => ({ @@ -260,26 +258,26 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole const emails = classroomTransferState.imports.map((i) => i.email); const crossRefUsers = async () => { try { - console.log(user.entities); const { data: nonExistantUsers } = await axios.post("/api/users/controller?op=dontExist", { emails }); const { data: nonEntityUsers } = await axios.post("/api/users/controller?op=entityCheck", { entities: user.entities, emails }); const { data: alreadyPlaced } = await axios.post("/api/users/controller?op=crossRefClassrooms", { sets: classroomTransferState.imports.map((info) => ({ email: info.email, classroom: info.groupName - })) + })), + entity }); const excludeEmails = new Set([ ...nonExistantUsers, - ...nonEntityUsers, + ...nonEntityUsers.map((o: any) => o.email), ...alreadyPlaced ]); const filteredImports = classroomTransferState.imports.filter(i => !excludeEmails.has(i.email)); const nonExistantEmails = new Set(nonExistantUsers); - const nonEntityEmails = new Set(nonEntityUsers); + const nonEntityEmails = new Set(nonEntityUsers.map((o: any) => o.email)); const alreadyPlacedEmails = new Set(alreadyPlaced); setClassroomTransferState((prev) => ({ @@ -287,7 +285,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole stage: 2, imports: filteredImports, notFoundUsers: classroomTransferState.imports.filter(i => nonExistantEmails.has(i.email)), - otherEntityUsers: classroomTransferState.imports.filter(i => nonEntityEmails.has(i.email)), + otherEntityUsers: classroomTransferState.imports.filter(i => nonEntityEmails.has(i.email)) + .map(user => { + const nonEntityUser = nonEntityUsers.find((o: any) => o.email === user.email); + return { + ...user, + entityLabels: nonEntityUser?.names || [] + }; + }), alreadyInClass: classroomTransferState.imports.filter(i => alreadyPlacedEmails.has(i.email)), })); } catch (error) { @@ -299,14 +304,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole crossRefUsers(); } - }, [classroomTransferState.imports, user.entities, classroomTransferState.stage]) + }, [classroomTransferState.imports, user.entities, classroomTransferState.stage, entity]) // Stage 3 - Classroom Filter // - See if there are classrooms with same name but different admin // - Find which new classrooms need to be created useEffect(() => { const crossRefClassrooms = async () => { - const classrooms = new Set(classroomTransferState.imports.map((i) => i.groupName)); + const classrooms = Array.from(new Set(classroomTransferState.imports.map((i) => i.groupName))); try { const { data: notOwnedClassroomsSameName } = await axios.post("/api/groups/controller?op=crossRefOwnership", { @@ -362,7 +367,42 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole try { setIsLoading(true); - const groupedUsers = classroomTransferState.imports.reduce((acc, user) => { + + const getIds = async () => { + try { + const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", { + emails: classroomTransferState.imports.map((u) => u.email) + }); + + return classroomTransferState.imports.map(importUser => { + const matchingUser = emailIdMap.find((mapping: any) => mapping.email === importUser.email); + return { + ...importUser, + id: matchingUser?.id || undefined + }; + }); + } catch (error) { + toast.error("Something went wrong, please try again later!"); + } + }; + + const imports = await getIds(); + + if (!imports) return; + + + await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", { + ids: imports.map((u) => u.id), + entity + }); + + await axios.post("/api/users/controller?op=assignToEntity", { + ids: imports.map((u) => u.id), + entity + }); + + + const groupedUsers = imports.reduce((acc, user) => { if (!acc[user.groupName]) { acc[user.groupName] = []; } @@ -385,33 +425,38 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole return axios.post('/api/groups', groupData); }); + const existingGroupUsers = Object.fromEntries( Object.entries(groupedUsers) .filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName)) ); - const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', { - names: Object.keys(existingGroupUsers), - userEmails: Object.values(existingGroupUsers).flat().map(user => user.email) - }).then(response => response.data); + let updatePromises: Promise[] = []; - const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => { - acc[groupNameToId[groupName]] = users; - return acc; - }, {} as Record); + if (Object.keys(existingGroupUsers).length > 0) { + const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', { + names: Object.keys(existingGroupUsers), + userEmails: Object.values(existingGroupUsers).flat().map(user => user.email) + }).then(response => response.data); - const updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => { - const userIds = users.map(user => userEmailToId[user.email]); - return axios.patch(`/api/groups/${groupId}`, { - participants: userIds + const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => { + acc[groupNameToId[groupName]] = users; + return acc; + }, {} as Record); + + updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => { + const userIds = users.map(user => userEmailToId[user.email]); + return axios.patch(`/api/groups/${groupId}`, { + participants: userIds + }); }); - }); - + } + await Promise.all([ ...createGroupPromises, ...updatePromises ]); - + toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`); onFinish(); } catch (error) { @@ -537,14 +582,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
    - - ({ value: e.id, label: e.label }))} + onChange={(e) => setEntity(e?.value || undefined)} + isClearable={checkAccess(user, ["admin", "developer"])} + /> +
    {classroomTransferState.parsedExcel?.rows !== undefined && ( diff --git a/src/components/Tables/UserTable.tsx b/src/components/Tables/UserTable.tsx index 22a301eb..4c638a3e 100644 --- a/src/components/Tables/UserTable.tsx +++ b/src/components/Tables/UserTable.tsx @@ -51,12 +51,28 @@ const columns = [ ]; +const institutionsColumn = columnHelper.accessor('entityLabels', { + cell: (info): string => { + const value = info.getValue(); + if (!value || value.length === 0) { + return 'None'; + } + return value.join(', '); + }, + header: () => 'Institutions', +}) as unknown as typeof columns[0]; + const UserTable: React.FC<{ users: UserImport[] }> = ({ users }) => { const [globalFilter, setGlobalFilter] = useState(''); + const tableColumns = [...columns]; + if (users.some(user => 'entityLabels' in user)) { + tableColumns.push(institutionsColumn); + } + const table = useReactTable({ data: users, - columns, + columns: tableColumns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), diff --git a/src/interfaces/IUserImport.ts b/src/interfaces/IUserImport.ts index ddd5067d..59da2728 100644 --- a/src/interfaces/IUserImport.ts +++ b/src/interfaces/IUserImport.ts @@ -16,4 +16,5 @@ export interface UserImport { passport_id: string; phone: string; }; + entityLabels?: string[]; } diff --git a/src/pages/api/groups/controller.ts b/src/pages/api/groups/controller.ts index fd1cc8d5..73a8ed6c 100644 --- a/src/pages/api/groups/controller.ts +++ b/src/pages/api/groups/controller.ts @@ -25,6 +25,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { case 'getIds': res.status(200).json(await getIds(req.body)); break; + case 'deletePriorEntitiesGroups': + await deletePriorEntitiesGroups(req.body); + res.status(200).json({ ok: true }); + break; default: res.status(400).json({ error: 'Invalid operation!' }) } @@ -34,23 +38,47 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } async function crossRefOwnership(body: any): Promise { - const { userId, classrooms } = body; + const { userId, classrooms, entity } = body; - // First find which classrooms from input exist const existingClassrooms = await db.collection('groups') - .find({ name: { $in: classrooms } }) + .find({ + name: { $in: classrooms }, + admin: { $ne: userId } + }) .project({ name: 1, admin: 1, _id: 0 }) .toArray(); - // From those existing classrooms, return the ones where user is NOT the admin - return existingClassrooms - .filter(classroom => classroom.admin !== userId) - .map(classroom => classroom.name); + if (existingClassrooms.length === 0) { + return []; + } + + const adminUsers = await db.collection('users') + .find({ + id: { $in: existingClassrooms.map(classroom => classroom.admin) } + }) + .project({ id: 1, entities: 1, _id: 0 }) + .toArray(); + + const adminEntitiesMap = new Map( + adminUsers.map(admin => [ + admin.id, + admin.entities?.map((e: any) => e.id) || [] + ]) + ); + + return Array.from(new Set( + existingClassrooms + .filter(classroom => { + const adminEntities = adminEntitiesMap.get(classroom.admin) || []; + return adminEntities.includes(entity); + }) + .map(classroom => classroom.name) + )); } async function getIds(body: any): Promise> { const { names, userEmails } = body; - + const existingGroups: any[] = await db.collection('groups') .find({ name: { $in: names } }) .project({ name: 1, id: 1, _id: 0 }) @@ -72,3 +100,31 @@ async function getIds(body: any): Promise> { }, {} as Record) }; } + + +async function deletePriorEntitiesGroups(body: any) { + const { ids, entity } = body; + + if (!Array.isArray(ids) || ids.length === 0 || !entity) { + return; + } + + const users = await db.collection('users') + .find({ id: { $in: ids } }) + .project({ id: 1, entities: 1, _id: 0 }) + .toArray(); + + // if the user doesn't have the target entity mark them for all groups deletion + const toDeleteUserIds = users + .filter(user => !user.entities?.some((e: any) => e.id === entity)) + .map(user => user.id); + + if (toDeleteUserIds.length === 0) { + return; + } + + db.collection('groups').updateMany( + { participants: { $in: toDeleteUserIds } }, + { $pull: { participants: { $in: toDeleteUserIds } } } as any + ); +} diff --git a/src/pages/api/users/controller.ts b/src/pages/api/users/controller.ts index f0b16ee2..a1e9f6f4 100644 --- a/src/pages/api/users/controller.ts +++ b/src/pages/api/users/controller.ts @@ -29,7 +29,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { res.status(200).json(await entityCheck(req.body)); break; case 'crossRefClassrooms': - res.status(200).json(await crossRefClassrooms(req.body.sets)); + res.status(200).json(await crossRefClassrooms(req.body)); + break; + case 'getIds': + res.status(200).json(await getIds(req.body.emails)); + break; + case 'assignToEntity': + await assignToEntity(req.body); + res.status(200).json({"ok": true}); break; default: res.status(400).json({ error: 'Invalid operation!' }) @@ -68,9 +75,12 @@ async function dontExist(emails: string[]): Promise { } -async function entityCheck(body: Record): Promise { +async function entityCheck(body: Record): Promise> { const { entities, emails } = body; - + const pipeline = [ // Match users with the provided emails { @@ -99,20 +109,65 @@ async function entityCheck(body: Record): Promise { ] } }, - // Project only the email field + // Unwind the entities array (if it exists) + { + $unwind: { + path: "$entities", + preserveNullAndEmptyArrays: true + } + }, + // Lookup entity details from entities collection + { + $lookup: { + from: 'entities', + localField: 'entities.id', + foreignField: 'id', + as: 'entityDetails' + } + }, + // Unwind the entityDetails array + { + $unwind: { + path: "$entityDetails", + preserveNullAndEmptyArrays: true + } + }, + // Group by email to collect all entity names in an array + { + $group: { + _id: "$email", + email: { $first: "$email" }, + name: { + $push: { + $cond: [ + { $ifNull: ["$entityDetails.label", false] }, + "$entityDetails.label", + "$$REMOVE" + ] + } + } + } + }, + // Final projection to clean up { $project: { _id: 0, - email: 1 + email: 1, + name: 1 } } ]; const results = await db.collection('users').aggregate(pipeline).toArray(); - return results.map((result: any) => result.email); + return results.map(result => ({ + email: result.email as string, + names: result.name as string[] | undefined + })); } -async function crossRefClassrooms(sets: { email: string, classroom: string }[]) { +async function crossRefClassrooms(body: any) { + const { sets, entity } = body as { sets: { email: string, classroom: string }[], entity: string }; + const pipeline = [ // Match users with the provided emails { @@ -132,7 +187,6 @@ async function crossRefClassrooms(sets: { email: string, classroom: string }[]) $and: [ { $in: ['$$userId', '$participants'] }, { - // Match the classroom that corresponds to this user's email $let: { vars: { matchingSet: { @@ -153,6 +207,38 @@ async function crossRefClassrooms(sets: { email: string, classroom: string }[]) ] } } + }, + // Lookup admin's entities + { + $lookup: { + from: 'users', + let: { adminId: '$admin' }, + pipeline: [ + { + $match: { + $expr: { $eq: ['$id', '$$adminId'] } + } + } + ], + as: 'adminInfo' + } + }, + // Filter where admin has the target entity + { + $match: { + $expr: { + $in: [ + entity, + { + $map: { + input: { $arrayElemAt: ['$adminInfo.entities', 0] }, + as: 'entityObj', + in: '$$entityObj.id' + } + } + ] + } + } } ], as: 'matchingGroups' @@ -172,7 +258,54 @@ async function crossRefClassrooms(sets: { email: string, classroom: string }[]) } } ]; - + const results = await db.collection('users').aggregate(pipeline).toArray(); return results.map((result: any) => result.email); } + + +async function getIds(emails: string[]): Promise> { + const users = await db.collection('users') + .find({ email: { $in: emails } }) + .project({ email: 1, id: 1, _id: 0 }) + .toArray(); + + return users.map(user => ({ + email: user.email, + id: user.id + })); +} + + +async function assignToEntity(body: any) { + const { ids, entity } = body; + + if (!Array.isArray(ids) || ids.length === 0 || !entity) { + return; + } + + const users = await db.collection('users') + .find({ id: { $in: ids } }) + .project({ id: 1, entities: 1 }) + .toArray(); + + const toUpdateUsers = users.filter((u) => u.entities[0].id !== entity); + + if (toUpdateUsers.length > 0) { + const writes = users.map(user => ({ + updateOne: { + filter: { id: user.id }, + update: { + $set: { + entities: [{ + id: entity, + role: user.entities?.[0]?.role + }] + } + } + } + })); + + db.collection('users').bulkWrite(writes); + } +} \ No newline at end of file