Merged in feature/ExamGenRework (pull request #131)

Feature/ExamGenRework

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2025-01-06 09:12:01 +00:00
committed by Tiago Ribeiro
13 changed files with 496 additions and 354 deletions

View File

@@ -1,4 +1,3 @@
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import ListeningContext from "./listening"; import ListeningContext from "./listening";
import ReadingContext from "./reading"; import ReadingContext from "./reading";
@@ -30,7 +29,7 @@ const LevelContext: React.FC<Props> = ({ sectionId }) => {
)} )}
{(readingSection || listeningSection || hasReadingContext) && ( {(readingSection || listeningSection || hasReadingContext) && (
<div className="space-y-4 mb-4"> <div className="space-y-4 mb-4">
{readingSection || hasReadingContext && <ReadingContext sectionId={sectionId} module="level" />} {(readingSection !== undefined || hasReadingContext) && <ReadingContext sectionId={sectionId} module="level" />}
{listeningSection && <ListeningContext sectionId={sectionId} listeningSection={listeningSection} module="level" level={true} />} {listeningSection && <ListeningContext sectionId={sectionId} listeningSection={listeningSection} module="level" level={true} />}
</div> </div>
)} )}

View File

@@ -11,11 +11,10 @@ import { Module } from "@/interfaces";
interface Props { interface Props {
module: Module; module: Module;
sectionId: number; sectionId: number;
level?: boolean;
} }
const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) => { const ReadingContext: React.FC<Props> = ({ sectionId, module }) => {
const { dispatch } = useExamEditorStore(); const { dispatch } = useExamEditorStore();
const sectionState = useExamEditorStore( const sectionState = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
@@ -55,7 +54,7 @@ const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) =
} }
}); });
useEffect(()=> { useEffect(() => {
if (readingPart.text === undefined) { if (readingPart.text === undefined) {
setTitle(''); setTitle('');
setContent(''); setContent('');
@@ -74,7 +73,9 @@ const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) =
useEffect(() => { useEffect(() => {
const passageRes = levelGenResults.find((res) => res.generating === "passage"); const passageRes = [...levelGenResults].reverse()
.find((res) => res.generating === "passage");
if (levelGenResults && passageRes) { if (levelGenResults && passageRes) {
setEditing(true); setEditing(true);
setTitle(passageRes.result[0].title); setTitle(passageRes.result[0].title);

View File

@@ -6,7 +6,7 @@ import Dropdown from "@/components/Dropdown";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import Writing from "../../Exercises/Writing"; import Writing from "../../Exercises/Writing";
import Speaking from "../../Exercises/Speaking"; import Speaking from "../../Exercises/Speaking";
import { ReactElement, ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect } from "react";
import { import {
DndContext, DndContext,
PointerSensor, PointerSensor,
@@ -43,7 +43,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const genResult = section?.genResult; const genResult = section?.genResult;
const generating = section?.generating; const generating = section?.generating;
const levelGenResults = section?.levelGenResults const levelGenResults = section?.levelGenResults;
const levelGenerating = section?.levelGenerating; const levelGenerating = section?.levelGenerating;
const sectionState = section?.state; const sectionState = section?.state;
@@ -66,134 +66,146 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
}, [genResult, dispatch, sectionId, currentModule]); }, [genResult, dispatch, sectionId, currentModule]);
useEffect(() => { const handleExerciseGen = (
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("exercises"))) { results: any[],
const newExercises = levelGenResults assignExercisesFn: (results: any[]) => any[],
.filter(res => res.generating.startsWith("exercises")) {
.map(res => res.result[0].exercises) sectionId,
.flat(); currentModule,
sectionState,
const updates = [ levelGenerating,
{ levelGenResults
type: "UPDATE_SECTION_STATE", }: {
payload: { sectionId: number;
sectionId, currentModule: string;
module: "level", sectionState: ExamPart;
update: { levelGenerating?: Generating[];
exercises: [...(sectionState as ExamPart).exercises, ...newExercises] levelGenResults: any[];
} }
} ) => {
}, const nonWritingOrSpeaking = results[0]?.generating.startsWith("exercises");
{
type: "UPDATE_SECTION_SINGLE_FIELD", const updates = [
payload: { {
sectionId, type: "UPDATE_SECTION_STATE",
module: currentModule, payload: {
field: "levelGenerating", sectionId,
value: levelGenerating?.filter(g => !g?.startsWith("exercises")) module: "level",
} update: {
}, exercises: [
{ ...sectionState.exercises,
type: "UPDATE_SECTION_SINGLE_FIELD", ...assignExercisesFn(results)
payload: { ]
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !res.generating.startsWith("exercises"))
} }
} }
] as Action[]; },
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g =>
nonWritingOrSpeaking
? !g?.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(g)
)
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res =>
nonWritingOrSpeaking
? !res.generating.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(res.generating)
)
}
}
] as Action[];
updates.forEach(update => dispatch(update));
};
updates.forEach(update => dispatch(update)); useEffect(() => {
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("exercises"))) {
const results = levelGenResults.filter(res =>
res.generating.startsWith("exercises")
);
const assignExercises = (results: any[]) =>
results
.map(res => res.result[0].exercises)
.flat();
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => { useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating === "writing_letter" || res.generating === "writing_2")) { if (levelGenResults && levelGenResults?.some(res =>
const results = levelGenResults.filter(res => res.generating === "writing_letter" || res.generating === "writing_2"); res.generating === "writing_letter" || res.generating === "writing_2"
)) {
const results = levelGenResults.filter(res =>
res.generating === "writing_letter" || res.generating === "writing_2"
);
const updates = [ const assignExercises = (results: any[]) =>
results.map(res => ({
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
}) as WritingExercise);
handleExerciseGen(
results,
assignExercises,
{ {
type: "UPDATE_SECTION_STATE", sectionId,
payload: { currentModule,
sectionId, sectionState: sectionState as ExamPart,
module: "level", levelGenerating,
update: { levelGenResults
exercises: [...(sectionState as ExamPart).exercises,
...results.map((res) => {
return {
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
} as WritingExercise;
})
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating))
}
} }
] as Action[]; );
updates.forEach(update => dispatch(update));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => { useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("speaking"))) { if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("speaking"))) {
const results = levelGenResults.filter(res => res.generating.startsWith("speaking")); const results = levelGenResults.filter(res =>
const updates = [ res.generating.startsWith("speaking")
{ );
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises,
...results.map(createSpeakingExercise)
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating))
}
}
] as Action[];
updates.forEach(update => dispatch(update)); const assignExercises = (results: any[]) =>
results.map(createSpeakingExercise);
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
@@ -243,7 +255,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const onFocus = (questionId: string, id: string | undefined) => { const onFocus = (questionId: string, id: string | undefined) => {
if (id) { if (id) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id} } }) dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id } } })
} }
} }
@@ -284,9 +296,9 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
{currentModule === "level" && ( {currentModule === "level" && (
<> <>
{ {
questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises" questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises"
&& background(<span className="flex justify-center">Generated exercises will appear here!</span>)} && background(<span className="flex justify-center">Generated exercises will appear here!</span>)}
{levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && <GenLoader module={currentModule} className="mt-4" />} {levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && <GenLoader module={currentModule} className="mt-4" />}
</>) </>)
} }
</DndContext > </DndContext >

View File

@@ -38,11 +38,7 @@ const SectionPicker: React.FC<Props> = ({
const newValue = currentValue === value ? undefined : value; const newValue = currentValue === value ? undefined : value;
setSelectedValue(newValue); setSelectedValue(newValue);
let update = {}; let update = {};
if (module == "reading") { if (module === "listening") {
update = {
text: undefined
}
} else {
if (state.audio?.source) { if (state.audio?.source) {
URL.revokeObjectURL(state.audio.source) URL.revokeObjectURL(state.audio.source)
} }

View File

@@ -15,10 +15,8 @@ import { usePersistentExamStore } from "@/stores/exam";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import ListeningComponents from "./listening/components"; import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components"; import ReadingComponents from "./reading/components";
import WritingComponents from "./writing/components";
import SpeakingComponents from "./speaking/components"; import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker"; import SectionPicker from "./Shared/SectionPicker";
import SettingsDropdown from "./Shared/SettingsDropdown";
const LevelSettings: React.FC = () => { const LevelSettings: React.FC = () => {

View File

@@ -14,19 +14,28 @@ import UserTable from '../Tables/UserTable';
import ParseExcelErrors from './ExcelError'; import ParseExcelErrors from './ExcelError';
import { errorsByRows } from '@/utils/excel.errors'; import { errorsByRows } from '@/utils/excel.errors';
import { ClassroomTransferState } from '../Imports/StudentClassroomTransfer'; import { ClassroomTransferState } from '../Imports/StudentClassroomTransfer';
import { ExcelUserDuplicatesMap } from './User';
const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ state }) => { const ClassroomImportSummary: React.FC<{ state: ClassroomTransferState }> = ({ state }) => {
const [showErrorsModal, setShowErrorsModal] = useState(false); const [showErrorsModal, setShowErrorsModal] = useState(false);
const [showDuplicatesModal, setShowDuplicatesModal] = useState(false); const [showDuplicatesModal, setShowDuplicatesModal] = useState(false);
const [showNotFoundModal, setShowNotFoundModal] = useState(false); const [showNotFoundModal, setShowNotFoundModal] = useState(false);
const [showOtherEntityModal, setShowOtherEntityModal] = useState(false); const [showOtherEntityModal, setShowOtherEntityModal] = useState(false);
const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false); const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false);
const [showNotOwnedModal, setShowNotOwnedModal] = useState(false); const [showNotOwnedModal, setShowNotOwnedModal] = useState(false);
const [showMismatchesModal, setShowMismatchesModal] = useState(false);
const errorCount = state.parsedExcel?.errors ? const errorCount = state.parsedExcel?.errors ?
Object.entries(errorsByRows(state.parsedExcel.errors)).length : 0; Object.entries(errorsByRows(state.parsedExcel.errors)).length : 0;
const fieldMapper: { [key: string]: string } = {
"firstName": "First Name",
"lastName": "Last Name",
"studentID": "Student ID",
"phone": "Phone Number",
"passport_id": "Passport/National ID",
"country": "Country"
}
return ( return (
<> <>
<Card> <Card>
@@ -120,11 +129,11 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</div> </div>
)} )}
{state.duplicatedRows && state.duplicatedRows.count > 0 && ( {state.duplicatedRows.length > 0 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaExclamationCircle className="h-5 w-5 text-yellow-500" /> <FaExclamationCircle className="h-5 w-5 text-yellow-500" />
<span>{state.duplicatedRows.count} duplicate entries in file</span> <span>{state.duplicatedRows.length} duplicate entries in file</span>
</div> </div>
<button <button
onClick={() => setShowDuplicatesModal(true)} onClick={() => setShowDuplicatesModal(true)}
@@ -135,6 +144,21 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</div> </div>
)} )}
{state.userMismatches.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaExclamationTriangle className="h-5 w-5 text-orange-500" />
<span>{state.userMismatches.length} users with mismatched information</span>
</div>
<button
onClick={() => setShowMismatchesModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View mismatches
</button>
</div>
)}
{errorCount > 0 && ( {errorCount > 0 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -151,73 +175,79 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
)} )}
</div> </div>
{((state.duplicatedRows?.count ?? 0) > 0 || errorCount > 0 || {(state.duplicatedRows.length > 0 || state.userMismatches.length > 0 || errorCount > 0 ||
state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 || state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 ||
state.notOwnedClassrooms.length > 0) && ( state.notOwnedClassrooms.length > 0) && (
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6"> <div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="mt-1"> <div className="mt-1">
<FaExclamationTriangle className="h-5 w-5 text-red-400" /> <FaExclamationTriangle className="h-5 w-5 text-red-400" />
</div> </div>
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3">
<p className="font-medium text-gray-900"> <p className="font-medium text-gray-900">
The following will be excluded from transfer: The following will be excluded from transfer:
</p> </p>
<ul className="space-y-4"> <ul className="space-y-4">
{state.notFoundUsers.length > 0 && ( {state.notFoundUsers.length > 0 && (
<li> <li>
<div className="text-gray-700"> <div className="text-gray-700">
<span className="font-medium">{state.notFoundUsers.length}</span> users not found <span className="font-medium">{state.notFoundUsers.length}</span> users not found
</div>
</li>
)}
{state.otherEntityUsers.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.otherEntityUsers.length}</span> users from different entities
</div>
</li>
)}
{state.notOwnedClassrooms.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.notOwnedClassrooms.length}</span> classrooms not owned:
<div className="mt-1 ml-4 text-sm text-gray-600">
{state.notOwnedClassrooms.join(', ')}
</div> </div>
</div> </li>
</li> )}
)} {state.otherEntityUsers.length > 0 && (
{state.duplicatedRows && state.duplicatedRows.count > 0 && ( <li>
<li> <div className="text-gray-700">
<div className="text-gray-700"> <span className="font-medium">{state.otherEntityUsers.length}</span> users from different entities
<span className="font-medium">{state.duplicatedRows.count}</span> duplicate entries </div>
</div> </li>
</li> )}
)} {state.notOwnedClassrooms.length > 0 && (
{state.alreadyInClass && state.alreadyInClass.length > 0 && ( <li>
<li> <div className="text-gray-700">
<div className="text-gray-700"> <span className="font-medium">{state.notOwnedClassrooms.length}</span> classrooms not owned:
<span className="font-medium">{state.alreadyInClass.length}</span> users that are already assigned to the classroom <div className="mt-1 ml-4 text-sm text-gray-600">
</div> {state.notOwnedClassrooms.join(', ')}
</li> </div>
)} </div>
{errorCount > 0 && ( </li>
<li> )}
<div className="text-gray-700"> {state.duplicatedRows.length > 0 && (
<span className="font-medium">{errorCount}</span> rows with invalid information <li>
</div> <div className="text-gray-700">
</li> <span className="font-medium">{state.duplicatedRows.length}</span> duplicate entries
)} </div>
</ul> </li>
)}
{state.userMismatches.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.userMismatches.length}</span> users with mismatched information
</div>
</li>
)}
{state.alreadyInClass && state.alreadyInClass.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.alreadyInClass.length}</span> users that are already assigned to the classroom
</div>
</li>
)}
{errorCount > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{errorCount}</span> rows with invalid information
</div>
</li>
)}
</ul>
</div>
</div> </div>
</div> </div>
</div> )}
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Modals */}
<Modal isOpen={showErrorsModal} onClose={() => setShowErrorsModal(false)}> <Modal isOpen={showErrorsModal} onClose={() => setShowErrorsModal(false)}>
<> <>
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
@@ -283,53 +313,59 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
<FaExclamationCircle className="w-5 h-5 text-yellow-500" /> <FaExclamationCircle className="w-5 h-5 text-yellow-500" />
<h2 className="text-lg font-semibold text-gray-900">Duplicate Entries</h2> <h2 className="text-lg font-semibold text-gray-900">Duplicate Entries</h2>
</div> </div>
{state.duplicatedRows && ( <div className="space-y-3">
<div className="space-y-6"> {state.duplicatedRows.map((duplicate) => (
{(Object.keys(state.duplicatedRows.duplicates) as Array<keyof ExcelUserDuplicatesMap>).map(field => { <div
const duplicates = Array.from(state.duplicatedRows!.duplicates[field].entries()) key={`${duplicate.email}-${duplicate.classroom}-${duplicate.rowNumber}`}
.filter((entry): entry is [string, number[]] => entry[1].length > 1); className="group relative rounded-lg border border-gray-200 bg-gray-50 p-3 hover:bg-gray-100 transition-colors"
>
if (duplicates.length === 0) return null; <div className="flex items-start justify-between">
<div>
return ( <span className="font-medium text-gray-900">Row {duplicate.rowNumber}</span>
<div key={field} className="relative"> <div className="mt-1 text-sm text-gray-600">
<div className="flex items-center gap-2 mb-3"> Email: <span className="text-blue-600 font-medium">{duplicate.email}</span>
<h2 className="text-md font-medium text-gray-700">
{field} duplicates
</h2>
<span className="text-xs text-gray-500 ml-auto">
{duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'}
</span>
</div> </div>
<div className="mt-1 text-sm text-gray-600">
<div className="space-y-2"> Classroom: <span className="text-blue-600 font-medium">{duplicate.classroom}</span>
{duplicates.map(([value, rows]) => (
<div
key={value}
className="group relative rounded-lg border border-gray-200 bg-gray-50 p-3 hover:bg-gray-100 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<span className="font-medium text-gray-900">{value}</span>
<div className="mt-1 text-sm text-gray-600">
Appears in rows:
<span className="ml-1 text-blue-600 font-medium">
{rows.join(', ')}
</span>
</div>
</div>
<span className="text-xs text-gray-500">
{rows.length} occurrences
</span>
</div>
</div>
))}
</div> </div>
</div> </div>
); </div>
})} </div>
</div> ))}
)} </div>
</>
</Modal>
<Modal isOpen={showMismatchesModal} onClose={() => setShowMismatchesModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaExclamationTriangle className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-semibold text-gray-900">Mismatched User Information</h2>
</div>
<div className="space-y-6">
{state.userMismatches.map((mismatch) => (
<div
key={mismatch.email}
className="relative rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div className="mb-2 font-medium text-gray-900">
Email: {mismatch.email}
</div>
<div className="text-sm text-gray-600">
Rows: {mismatch.rows.join(', ')}
</div>
<div className="mt-3 space-y-2">
{mismatch.mismatches.map((field) => (
<div key={field.field} className="pl-4 border-l-2 border-orange-500">
<div className="font-medium text-gray-700">{fieldMapper[field.field]}:</div>
<div className="mt-1 text-sm text-gray-600">
Values: {field.values.join(', ')}
</div>
</div>
))}
</div>
</div>
))}
</div>
</> </>
</Modal> </Modal>
</> </>

View File

@@ -116,7 +116,7 @@ const CodeGenImportSummary: React.FC<Props> = ({ infos, parsedExcel, duplicateRo
if (duplicates.length === 0) return null; if (duplicates.length === 0) return null;
return ( return (
<div key={field} className="ml-4 text-sm text-gray-600"> <div key={field} className="ml-4 text-sm text-gray-600">
<span className="text-gray-500">{field}:</span> rows { <span className="text-gray-500">{fieldMapper[field]}:</span> rows {
duplicates.map(([_, rows]) => rows.join(', ')).join('; ') duplicates.map(([_, rows]) => rows.join(', ')).join('; ')
} }
</div> </div>

View File

@@ -162,7 +162,7 @@ const UserImportSummary: React.FC<Props> = ({ parsedExcel, newUsers, enlistedUse
if (duplicates.length === 0) return null; if (duplicates.length === 0) return null;
return ( return (
<div key={field} className="ml-4 text-sm text-gray-600"> <div key={field} className="ml-4 text-sm text-gray-600">
<span className="text-gray-500">{field}:</span> rows { <span className="text-gray-500">{fieldMapper[field]}:</span> rows {
duplicates.map(([_, rows]) => rows.join(', ')).join('; ') duplicates.map(([_, rows]) => rows.join(', ')).join('; ')
} }
</div> </div>

View File

@@ -18,20 +18,37 @@ import UserTable from "../Tables/UserTable";
import ClassroomImportSummary from "../ImportSummaries/Classroom"; import ClassroomImportSummary from "../ImportSummaries/Classroom";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { useRouter } from "next/router";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
export interface DuplicateClassroom {
rowNumber: number;
email: string;
classroom: string;
}
export interface Mismatches {
email: string;
rows: number[];
mismatches: {
field: string;
values: any[];
}[];
}
export interface ClassroomTransferState { export interface ClassroomTransferState {
stage: number; stage: number;
parsedExcel: { rows?: any[]; errors?: any[] } | undefined; parsedExcel: { rows?: any[]; errors?: any[] } | undefined;
duplicatedRows: { duplicates: ExcelUserDuplicatesMap, count: number } | undefined; duplicatedRows: DuplicateClassroom[];
userMismatches: Mismatches[];
imports: UserImport[]; imports: UserImport[];
notFoundUsers: UserImport[]; notFoundUsers: UserImport[];
otherEntityUsers: UserImport[]; otherEntityUsers: UserImport[];
alreadyInClass: UserImport[]; alreadyInClass: UserImport[];
notOwnedClassrooms: string[]; notOwnedClassrooms: string[];
classroomsToCreate: string[]; validClassrooms: string[];
} }
@@ -43,15 +60,18 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
const [classroomTransferState, setClassroomTransferState] = useState<ClassroomTransferState>({ const [classroomTransferState, setClassroomTransferState] = useState<ClassroomTransferState>({
stage: 0, stage: 0,
parsedExcel: undefined, parsedExcel: undefined,
duplicatedRows: undefined, duplicatedRows: [],
userMismatches: [],
imports: [], imports: [],
notFoundUsers: [], notFoundUsers: [],
otherEntityUsers: [], otherEntityUsers: [],
alreadyInClass: [], alreadyInClass: [],
notOwnedClassrooms: [], notOwnedClassrooms: [],
classroomsToCreate: [] validClassrooms: []
}) })
const router = useRouter();
const { openFilePicker, filesContent, clear } = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
@@ -166,89 +186,126 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent, entity]);
// Stage 1 - Excel Parsing // Stage 1 - Excel Parsing
// - Find duplicate rows // - Group rows by emails
// - Find duplicate rows (email + classroom)
// - Find email and other fields mismatch except classroom
// - Parsing errors // - Parsing errors
// - Set the data // - Set the data
useEffect(() => { useEffect(() => {
if (classroomTransferState.parsedExcel && classroomTransferState.parsedExcel.rows) { if (classroomTransferState.parsedExcel && classroomTransferState.parsedExcel.rows) {
const duplicates: ExcelUserDuplicatesMap = {
studentID: new Map(),
email: new Map(),
passport_id: new Map(),
phone: new Map()
};
const duplicateValues = new Set<string>();
const duplicateRowIndices = new Set<number>();
const errorRowIndices = new Set( const errorRowIndices = new Set(
classroomTransferState.parsedExcel.errors?.map(error => error.row) || [] classroomTransferState.parsedExcel.errors?.map(error => error.row) || []
); );
const emailClassroomMap = new Map<string, Map<string, number[]>>(); // email -> (group -> row numbers)
const emailRowMap = new Map<string, Map<number, any>>(); // email -> (row number -> row data)
// Group rows by email and assign row data
classroomTransferState.parsedExcel.rows.forEach((row, index) => { classroomTransferState.parsedExcel.rows.forEach((row, index) => {
if (!errorRowIndices.has(index + 2)) { const rowNum = index + 2;
(Object.keys(duplicates) as Array<keyof ExcelUserDuplicatesMap>).forEach(field => { if (!errorRowIndices.has(rowNum) && row !== null) {
if (row !== null) { const email = row.email.toString().trim().toLowerCase();
const value = row[field]; const classroom = row.group;
if (value) {
if (!duplicates[field].has(value)) { if (!emailRowMap.has(email)) {
duplicates[field].set(value, [index + 2]); emailRowMap.set(email, new Map());
} else { }
const existingRows = duplicates[field].get(value); emailRowMap.get(email)!.set(rowNum, row);
if (existingRows) {
existingRows.push(index + 2); if (!emailClassroomMap.has(email)) {
duplicateValues.add(value); emailClassroomMap.set(email, new Map());
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum)); }
} const classroomMap = emailClassroomMap.get(email)!;
} if (!classroomMap.has(classroom)) {
} classroomMap.set(classroom, []);
} }
}); classroomMap.get(classroom)!.push(rowNum);
} }
}); });
const infos = classroomTransferState.parsedExcel.rows const duplicatedRows: DuplicateClassroom[] = [];
.map((row, index) => { const userMismatches: Mismatches[] = [];
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) { const validRows = [];
return undefined;
for (const [email, classroomMap] of emailClassroomMap) {
const rowDataMap = emailRowMap.get(email)!;
const allRowsForEmail = Array.from(rowDataMap.keys());
// Check for duplicates (same email + classroom)
for (const [classroom, rowNumbers] of classroomMap) {
if (rowNumbers.length > 1) {
rowNumbers.forEach(row => duplicatedRows.push({
rowNumber: row,
email,
classroom
}));
} else {
validRows.push(rowNumbers[0]);
} }
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row; }
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
return undefined; // Check for mismatches in other fields
if (allRowsForEmail.length > 1) {
const fields = ['firstName', 'lastName', 'studentID', 'passport_id', 'phone'];
const mismatches: {field: string; values: any[]}[] = [];
fields.forEach(field => {
const uniqueValues = new Set(
allRowsForEmail.map(rowNum => rowDataMap.get(rowNum)![field])
);
if (uniqueValues.size > 1) {
mismatches.push({
field,
values: Array.from(uniqueValues)
});
}
});
if (mismatches.length > 0) {
userMismatches.push({
email,
rows: allRowsForEmail,
mismatches
});
} }
}
return { }
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), const imports = validRows
passport_id: passport_id?.toString().trim() || undefined, .map(rowNum => classroomTransferState.parsedExcel!.rows![rowNum - 2])
groupName: group, .filter((row): row is any => row !== null)
studentID, .map(row => ({
demographicInformation: { email: row.email.toString().trim().toLowerCase(),
country: country?.countryCode, name: `${row.firstName ?? ""} ${row.lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined, passport_id: row.passport_id?.toString().trim() || undefined,
phone: phone.toString(), groupName: row.group,
}, studentID: row.studentID,
entity: undefined, demographicInformation: {
type: undefined country: row.country?.countryCode,
} as UserImport; passport_id: row.passport_id?.toString().trim() || undefined,
}) phone: row.phone.toString(),
.filter((item): item is UserImport => item !== undefined); },
entity: undefined,
type: undefined
} as UserImport));
// On import reset state except excel parsing // On import reset state except excel parsing
setClassroomTransferState((prev) => ({ setClassroomTransferState((prev) => ({
...prev, ...prev,
stage: 1, stage: 1,
duplicatedRows: { duplicates, count: duplicateRowIndices.size }, duplicatedRows,
imports: infos, userMismatches,
imports,
notFoundUsers: [], notFoundUsers: [],
otherEntityUsers: [], otherEntityUsers: [],
notOwnedClassrooms: [], notOwnedClassrooms: [],
classroomsToCreate: [], validClassrooms: [],
})); }));
} }
}, [classroomTransferState.parsedExcel]); }, [classroomTransferState.parsedExcel, entity]);
// Stage 2 - Student Filter // Stage 2 - Student Filter
// - Filter non existant students // - Filter non existant students
@@ -323,7 +380,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
...prev, ...prev,
stage: 3, stage: 3,
notOwnedClassrooms: notOwnedClassroomsSameName, notOwnedClassrooms: notOwnedClassroomsSameName,
classroomsToCreate: Array.from(classrooms).filter( validClassrooms: Array.from(classrooms).filter(
(name) => !new Set(notOwnedClassroomsSameName).has(name) (name) => !new Set(notOwnedClassroomsSameName).has(name)
) )
})) }))
@@ -334,7 +391,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) { if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) {
crossRefClassrooms(); crossRefClassrooms();
} }
}, [classroomTransferState.imports, classroomTransferState.stage, user.id]) }, [classroomTransferState.imports, classroomTransferState.stage, user.id, entity])
const clearAndReset = () => { const clearAndReset = () => {
@@ -342,13 +399,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
setClassroomTransferState({ setClassroomTransferState({
stage: 0, stage: 0,
parsedExcel: undefined, parsedExcel: undefined,
duplicatedRows: undefined, duplicatedRows: [],
userMismatches: [],
imports: [], imports: [],
notFoundUsers: [], notFoundUsers: [],
otherEntityUsers: [], otherEntityUsers: [],
alreadyInClass: [], alreadyInClass: [],
notOwnedClassrooms: [], notOwnedClassrooms: [],
classroomsToCreate: [] validClassrooms: []
}); });
clear(); clear();
}; };
@@ -367,7 +425,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
try { try {
setIsLoading(true); setIsLoading(true);
const getIds = async () => { const getIds = async () => {
try { try {
const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", { const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", {
@@ -390,7 +447,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
if (!imports) return; if (!imports) return;
await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", { await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", {
ids: imports.map((u) => u.id), ids: imports.map((u) => u.id),
entity entity
@@ -401,6 +457,9 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
entity entity
}); });
const { data: existingGroupsMap } = await axios.post("/api/groups/controller?op=existantGroupIds", {
names: classroomTransferState.validClassrooms
});
const groupedUsers = imports.reduce((acc, user) => { const groupedUsers = imports.reduce((acc, user) => {
if (!acc[user.groupName]) { if (!acc[user.groupName]) {
@@ -409,12 +468,18 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
acc[user.groupName].push(user); acc[user.groupName].push(user);
return acc; return acc;
}, {} as Record<string, UserImport[]>); }, {} as Record<string, UserImport[]>);
// ##############
// # New Groups #
// ##############
const newGroupUsers = Object.fromEntries( const newGroupUsers = Object.fromEntries(
Object.entries(groupedUsers) Object.entries(groupedUsers)
.filter(([groupName]) => classroomTransferState.classroomsToCreate.includes(groupName)) .filter(([groupName]) =>
classroomTransferState.validClassrooms.includes(groupName) &&
!existingGroupsMap[groupName]
)
); );
const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => { const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => {
const groupData: Partial<Group> = { const groupData: Partial<Group> = {
admin: user.id, admin: user.id,
@@ -424,25 +489,33 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
}; };
return axios.post('/api/groups', groupData); return axios.post('/api/groups', groupData);
}); });
// ###################
const existingGroupUsers = Object.fromEntries( // # Existant Groups #
// ###################
const allExistingUsers = Object.fromEntries(
Object.entries(groupedUsers) Object.entries(groupedUsers)
.filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName)) .filter(([groupName]) =>
!classroomTransferState.validClassrooms.includes(groupName) ||
existingGroupsMap[groupName]
)
); );
let updatePromises: Promise<any>[] = []; let updatePromises: Promise<any>[] = [];
if (Object.keys(allExistingUsers).length > 0) {
const { data: { groups: groupNameToId, users: userEmailToId } } = await axios.post('/api/groups/controller?op=getIds', {
names: Object.keys(allExistingUsers),
userEmails: Object.values(allExistingUsers).flat().map(user => user.email)
});
if (Object.keys(existingGroupUsers).length > 0) { const existingGroupsWithIds = Object.entries(allExistingUsers).reduce((acc, [groupName, users]) => {
const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', { const groupId = groupNameToId[groupName];
names: Object.keys(existingGroupUsers), if (groupId) {
userEmails: Object.values(existingGroupUsers).flat().map(user => user.email) acc[groupId] = users;
}).then(response => response.data); }
const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => {
acc[groupNameToId[groupName]] = users;
return acc; return acc;
}, {} as Record<string, any[]>); }, {} as Record<string, UserImport[]>);
updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => { updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => {
const userIds = users.map(user => userEmailToId[user.email]); const userIds = users.map(user => userEmailToId[user.email]);
@@ -451,13 +524,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
}); });
}); });
} }
await Promise.all([ await Promise.all([
...createGroupPromises, ...createGroupPromises,
...updatePromises ...updatePromises
]); ]);
toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`); toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`);
router.replace("/classrooms");
onFinish(); onFinish();
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -22,6 +22,7 @@ import { IoInformationCircleOutline } from "react-icons/io5";
import { FaFileDownload } from "react-icons/fa"; import { FaFileDownload } from "react-icons/fa";
import { HiOutlineDocumentText } from "react-icons/hi"; import { HiOutlineDocumentText } from "react-icons/hi";
import UserImportSummary, { ExcelUserDuplicatesMap } from "@/components/ImportSummaries/User"; import UserImportSummary, { ExcelUserDuplicatesMap } from "@/components/ImportSummaries/User";
import { v4 } from "uuid";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
@@ -87,7 +88,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [entity, setEntity] = useState<{id: string | null, label: string | null}| undefined>(() => { const [entity, setEntity] = useState<{ id: string | null, label: string | null } | undefined>(() => {
if (!entities?.length) { if (!entities?.length) {
return undefined; return undefined;
} }
@@ -307,11 +308,11 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
})); }));
setNewUsers(newUsersList); setNewUsers(newUsersList);
const {data: emailEntityMap} = await axios.post("/api/users/controller?op=getEntities", { const { data: emailEntityMap } = await axios.post("/api/users/controller?op=getEntities", {
emails: dupes.map((x) => x.email) emails: dupes.map((x) => x.email)
}); });
const withLabels = dupes.map((u) => ({ const withLabels = dupes.map((u) => ({
...u, ...u,
entityLabels: emailEntityMap.find((e: any) => e.email === u.email)?.entityLabels || [] entityLabels: emailEntityMap.find((e: any) => e.email === u.email)?.entityLabels || []
})) }))
setDuplicatedUsers(withLabels); setDuplicatedUsers(withLabels);
@@ -339,18 +340,17 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`)) if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`))
return; return;
Promise.all(newUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, entity, from: user.id })))
.then(() => toast.success(`Successfully invited ${newUsers.length} registered student(s)!`))
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
if (newUsers.length > 0) { if (newUsers.length > 0) {
setIsLoading(true); setIsLoading(true);
try { try {
await axios.post("/api/batch_users", { users: newUsers.map((user) => ({ ...user, type, expiryDate })) }); const withIds = newUsers.map((user) => ({ ...user, type, expiryDate, id: v4() }));
toast.success(`Successfully added ${newUsers.length} user(s)!`); await axios.post("/api/batch_users", { users: withIds});
toast.success(`Successfully added ${withIds.length} user(s)!`);
Promise.all(withIds.map(async (u) => await axios.post(`/api/invites`, { to: u.id, entity: entity?.id, from: user.id })))
.then(() => toast.success(`Successfully invited ${withIds.length} registered student(s)!`))
.finally(() => {
if (withIds.length === 0) setIsLoading(false);
});
onFinish(); onFinish();
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -486,14 +486,14 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => { onChange={(e) => {
if (!e) { if (!e) {
setEntity(undefined); setEntity(undefined);
return; return;
} }
setEntity({ setEntity({
id: e?.value, id: e?.value,
label: e?.label label: e?.label
}); });
}} }}
isClearable={checkAccess(user, ["admin", "developer"])} isClearable={checkAccess(user, ["admin", "developer"])}
/> />
</div> </div>

View File

@@ -38,7 +38,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
passport_id?: string; passport_id?: string;
phone: string; phone: string;
}; };
entity?: string entity: { id: string, label: string }
entities: { id: string, role: string }[] entities: { id: string, role: string }[]
passwordHash: string | undefined; passwordHash: string | undefined;
passwordSalt: string | undefined; passwordSalt: string | undefined;
@@ -49,11 +49,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const salt = crypto.randomBytes(16).toString('base64'); const salt = crypto.randomBytes(16).toString('base64');
const hash = await scrypt.hash(user.passport_id, salt); const hash = await scrypt.hash(user.passport_id, salt);
const entity = await getEntityWithRoles(currentUser.entity!) const entity = await getEntityWithRoles(currentUser.entity!.id)
const defaultRole = findBy(entity?.roles || [], "isDefault", true) const defaultRole = findBy(entity?.roles || [], "isDefault", true)
currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }] currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }]
delete currentUser.entity
currentUser.email = currentUser.email.toLowerCase(); currentUser.email = currentUser.email.toLowerCase();
currentUser.passwordHash = hash; currentUser.passwordHash = hash;

View File

@@ -19,6 +19,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
else if (req.method === 'POST') { else if (req.method === 'POST') {
switch (op) { switch (op) {
case 'existantGroupIds':
res.status(200).json(await existantGroupIds(req.body.names));
break;
case 'crossRefOwnership': case 'crossRefOwnership':
res.status(200).json(await crossRefOwnership(req.body)); res.status(200).json(await crossRefOwnership(req.body));
break; break;
@@ -41,7 +44,7 @@ async function crossRefOwnership(body: any): Promise<string[]> {
const { userId, classrooms, entity } = body; const { userId, classrooms, entity } = body;
const existingClassrooms = await db.collection('groups') const existingClassrooms = await db.collection('groups')
.find({ .find({
name: { $in: classrooms }, name: { $in: classrooms },
admin: { $ne: userId } admin: { $ne: userId }
}) })
@@ -61,7 +64,7 @@ async function crossRefOwnership(body: any): Promise<string[]> {
const adminEntitiesMap = new Map( const adminEntitiesMap = new Map(
adminUsers.map(admin => [ adminUsers.map(admin => [
admin.id, admin.id,
admin.entities?.map((e: any) => e.id) || [] admin.entities?.map((e: any) => e.id) || []
]) ])
); );
@@ -104,7 +107,6 @@ async function getIds(body: any): Promise<Record<string, string>> {
async function deletePriorEntitiesGroups(body: any) { async function deletePriorEntitiesGroups(body: any) {
const { ids, entity } = body; const { ids, entity } = body;
if (!Array.isArray(ids) || ids.length === 0 || !entity) { if (!Array.isArray(ids) || ids.length === 0 || !entity) {
return; return;
} }
@@ -123,8 +125,33 @@ async function deletePriorEntitiesGroups(body: any) {
return; return;
} }
db.collection('groups').updateMany( const affectedGroups = await db.collection('groups')
.find({ participants: { $in: toDeleteUserIds } })
.project({ id: 1, _id: 0 })
.toArray();
await db.collection('groups').updateMany(
{ participants: { $in: toDeleteUserIds } }, { participants: { $in: toDeleteUserIds } },
{ $pull: { participants: { $in: toDeleteUserIds } } } as any { $pull: { participants: { $in: toDeleteUserIds } } } as any
); );
// delete groups that were updated and have no participants
await db.collection('groups').deleteMany({
id: { $in: affectedGroups.map(g => g.id) },
participants: { $size: 0 }
});
}
async function existantGroupIds(names: string[]) {
const existingGroups = await db.collection('groups')
.find({
name: { $in: names }
})
.project({ id: 1, name: 1, _id: 0 })
.toArray();
return existingGroups.reduce((acc, group) => {
acc[group.name] = group.id;
return acc;
}, {} as Record<string, string>);
} }

View File

@@ -124,7 +124,7 @@ export default function Home({ user, groups, entities }: Props) {
<ToastContainer /> <ToastContainer />
<Layout user={user} className="!gap-4"> <Layout user={user} className="!gap-4">
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)}> <Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]">
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} /> <StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
</Modal> </Modal>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">