Merged in develop (pull request #133)

Develop
This commit is contained in:
Tiago Ribeiro
2025-01-06 21:32:31 +00:00
14 changed files with 490 additions and 497 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]);
const handleExerciseGen = (
results: any[],
assignExercisesFn: (results: any[]) => any[],
{
sectionId,
currentModule,
sectionState,
levelGenerating,
levelGenResults
}: {
sectionId: number;
currentModule: string;
sectionState: ExamPart;
levelGenerating?: Generating[];
levelGenResults: any[];
}
) => {
const nonWritingOrSpeaking = results[0]?.generating.startsWith("exercises");
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [
...sectionState.exercises,
...assignExercisesFn(results)
]
}
}
},
{
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));
};
useEffect(() => { useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("exercises"))) { if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("exercises"))) {
const newExercises = levelGenResults const results = levelGenResults.filter(res =>
.filter(res => res.generating.startsWith("exercises")) res.generating.startsWith("exercises")
);
const assignExercises = (results: any[]) =>
results
.map(res => res.result[0].exercises) .map(res => res.result[0].exercises)
.flat(); .flat();
const updates = [ handleExerciseGen(
results,
assignExercises,
{ {
type: "UPDATE_SECTION_STATE",
payload: {
sectionId, sectionId,
module: "level", currentModule,
update: { sectionState: sectionState as ExamPart,
exercises: [...(sectionState as ExamPart).exercises, ...newExercises] levelGenerating,
levelGenResults
} }
} );
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !g?.startsWith("exercises"))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !res.generating.startsWith("exercises"))
}
}
] 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 === "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 => ({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises,
...results.map((res) => {
return {
...writingTask(res.generating === "writing_letter" ? 1 : 2), ...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt, prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay" variant: res.generating === "writing_letter" ? "letter" : "essay"
} as WritingExercise; }) 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)); 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.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 } } })
} }
} }

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,27 @@ 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 [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>
@@ -105,26 +113,11 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</div> </div>
)} )}
{state.notOwnedClassrooms.length > 0 && ( {state.duplicatedRows.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaLock className="h-5 w-5 text-red-500" />
<span>{`${state.notOwnedClassrooms.length} classroom${state.notOwnedClassrooms.length !== 1 ? 's' : ''} not owned`}</span>
</div>
<button
onClick={() => setShowNotOwnedModal(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 details
</button>
</div>
)}
{state.duplicatedRows && state.duplicatedRows.count > 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 +128,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,9 +159,8 @@ 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) && (
<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">
@@ -178,20 +185,18 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</div> </div>
</li> </li>
)} )}
{state.notOwnedClassrooms.length > 0 && ( {state.duplicatedRows.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.duplicatedRows.length}</span> duplicate entries
<div className="mt-1 ml-4 text-sm text-gray-600">
{state.notOwnedClassrooms.join(', ')}
</div>
</div> </div>
</li> </li>
)} )}
{state.duplicatedRows && state.duplicatedRows.count > 0 && (
{state.userMismatches.length > 0 && (
<li> <li>
<div className="text-gray-700"> <div className="text-gray-700">
<span className="font-medium">{state.duplicatedRows.count}</span> duplicate entries <span className="font-medium">{state.userMismatches.length}</span> users with mismatched information
</div> </div>
</li> </li>
)} )}
@@ -216,8 +221,6 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
)} )}
</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">
@@ -258,78 +261,65 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</> </>
</Modal> </Modal>
<Modal isOpen={showNotOwnedModal} onClose={() => setShowNotOwnedModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaLock className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Classrooms Not Owned</h2>
</div>
<div className="space-y-3">
{state.notOwnedClassrooms.map(classroom => (
<div
key={classroom}
className="flex justify-between items-center rounded-lg border border-gray-200 bg-gray-50 p-3"
>
<span className="text-gray-700">{classroom}</span>
</div>
))}
</div>
</>
</Modal>
<Modal isOpen={showDuplicatesModal} onClose={() => setShowDuplicatesModal(false)}> <Modal isOpen={showDuplicatesModal} onClose={() => setShowDuplicatesModal(false)}>
<> <>
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<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 => {
const duplicates = Array.from(state.duplicatedRows!.duplicates[field].entries())
.filter((entry): entry is [string, number[]] => entry[1].length > 1);
if (duplicates.length === 0) return null;
return (
<div key={field} className="relative">
<div className="flex items-center gap-2 mb-3">
<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 className="space-y-2">
{duplicates.map(([value, rows]) => (
<div <div
key={value} key={`${duplicate.email}-${duplicate.classroom}-${duplicate.rowNumber}`}
className="group relative rounded-lg border border-gray-200 bg-gray-50 p-3 hover:bg-gray-100 transition-colors" 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 className="flex items-start justify-between">
<div> <div>
<span className="font-medium text-gray-900">{value}</span> <span className="font-medium text-gray-900">Row {duplicate.rowNumber}</span>
<div className="mt-1 text-sm text-gray-600"> <div className="mt-1 text-sm text-gray-600">
Appears in rows: Email: <span className="text-blue-600 font-medium">{duplicate.email}</span>
<span className="ml-1 text-blue-600 font-medium"> </div>
{rows.join(', ')} <div className="mt-1 text-sm text-gray-600">
</span> Classroom: <span className="text-blue-600 font-medium">{duplicate.classroom}</span>
</div> </div>
</div> </div>
<span className="text-xs text-gray-500"> </div>
{rows.length} occurrences </div>
</span> ))}
</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> </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

@@ -4,7 +4,6 @@ import Modal from "../Modal";
import { useFilePicker } from "use-file-picker"; import { useFilePicker } from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import countryCodes from "country-codes-list"; import countryCodes from "country-codes-list";
import { ExcelUserDuplicatesMap } from "../ImportSummaries/User";
import { UserImport } from "@/interfaces/IUserImport"; import { UserImport } from "@/interfaces/IUserImport";
import axios from "axios"; import axios from "axios";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -18,20 +17,35 @@ 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[];
classroomsToCreate: string[];
} }
@@ -43,15 +57,16 @@ 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: [],
classroomsToCreate: []
}) })
const router = useRouter();
const { openFilePicker, filesContent, clear } = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
@@ -166,89 +181,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());
}
emailRowMap.get(email)!.set(rowNum, row);
if (!emailClassroomMap.has(email)) {
emailClassroomMap.set(email, new Map());
}
const classroomMap = emailClassroomMap.get(email)!;
if (!classroomMap.has(classroom)) {
classroomMap.set(classroom, []);
}
classroomMap.get(classroom)!.push(rowNum);
}
});
const duplicatedRows: DuplicateClassroom[] = [];
const userMismatches: Mismatches[] = [];
const validRows = [];
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 { } else {
const existingRows = duplicates[field].get(value); validRows.push(rowNumbers[0]);
if (existingRows) {
existingRows.push(index + 2);
duplicateValues.add(value);
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
}
}
} }
} }
// 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)
}); });
} }
}); });
const infos = classroomTransferState.parsedExcel.rows if (mismatches.length > 0) {
.map((row, index) => { userMismatches.push({
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) { email,
return undefined; rows: allRowsForEmail,
mismatches
});
}
} }
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
return undefined;
} }
return { const imports = validRows
email: email.toString().trim().toLowerCase(), .map(rowNum => classroomTransferState.parsedExcel!.rows![rowNum - 2])
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), .filter((row): row is any => row !== null)
passport_id: passport_id?.toString().trim() || undefined, .map(row => ({
groupName: group, email: row.email.toString().trim().toLowerCase(),
studentID, name: `${row.firstName ?? ""} ${row.lastName ?? ""}`.trim(),
passport_id: row.passport_id?.toString().trim() || undefined,
groupName: row.group,
studentID: row.studentID,
demographicInformation: { demographicInformation: {
country: country?.countryCode, country: row.country?.countryCode,
passport_id: passport_id?.toString().trim() || undefined, passport_id: row.passport_id?.toString().trim() || undefined,
phone: phone.toString(), phone: row.phone.toString(),
}, },
entity: undefined, entity: undefined,
type: undefined type: undefined
} as UserImport; } as UserImport));
})
.filter((item): item is UserImport => item !== undefined);
// 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
@@ -265,7 +317,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
email: info.email, email: info.email,
classroom: info.groupName classroom: info.groupName
})), })),
entity entity,
userId: user.id
}); });
const excludeEmails = new Set([ const excludeEmails = new Set([
@@ -304,51 +357,19 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
crossRefUsers(); crossRefUsers();
} }
}, [classroomTransferState.imports, user.entities, classroomTransferState.stage, entity]) }, [classroomTransferState.imports, user.entities, classroomTransferState.stage, entity, user.id])
// 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 = Array.from(new Set(classroomTransferState.imports.map((i) => i.groupName)));
try {
const { data: notOwnedClassroomsSameName } = await axios.post("/api/groups/controller?op=crossRefOwnership", {
userId: user.id,
classrooms
});
setClassroomTransferState((prev) => ({
...prev,
stage: 3,
notOwnedClassrooms: notOwnedClassroomsSameName,
classroomsToCreate: Array.from(classrooms).filter(
(name) => !new Set(notOwnedClassroomsSameName).has(name)
)
}))
} catch (error) {
toast.error("Something went wrong, please try again later!");
}
};
if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) {
crossRefClassrooms();
}
}, [classroomTransferState.imports, classroomTransferState.stage, user.id])
const clearAndReset = () => { const clearAndReset = () => {
setIsLoading(false); setIsLoading(false);
setClassroomTransferState({ setClassroomTransferState({
stage: 0, stage: 0,
parsedExcel: undefined, parsedExcel: undefined,
duplicatedRows: undefined, duplicatedRows: [],
userMismatches: [],
imports: [], imports: [],
notFoundUsers: [], notFoundUsers: [],
otherEntityUsers: [], otherEntityUsers: [],
alreadyInClass: [], alreadyInClass: [],
notOwnedClassrooms: [],
classroomsToCreate: []
}); });
clear(); clear();
}; };
@@ -367,6 +388,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
try { try {
setIsLoading(true); setIsLoading(true);
const classrooms = Array.from(new Set(classroomTransferState.imports.map((i) => i.groupName)));
const getIds = async () => { const getIds = async () => {
try { try {
@@ -390,7 +412,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 +422,9 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
entity entity
}); });
const { data: existingGroupsMap } = await axios.post("/api/groups/controller?op=existantGroupIds", {
names: classrooms
});
const groupedUsers = imports.reduce((acc, user) => { const groupedUsers = imports.reduce((acc, user) => {
if (!acc[user.groupName]) { if (!acc[user.groupName]) {
@@ -410,9 +434,15 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
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]) =>
classrooms.includes(groupName) &&
!existingGroupsMap[groupName]
)
); );
const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => { const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => {
@@ -425,24 +455,32 @@ 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]) =>
!classrooms.includes(groupName) ||
existingGroupsMap[groupName]
)
); );
let updatePromises: Promise<any>[] = []; let updatePromises: Promise<any>[] = [];
if (Object.keys(existingGroupUsers).length > 0) { if (Object.keys(allExistingUsers).length > 0) {
const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', { const { data: { groups: groupNameToId, users: userEmailToId } } = await axios.post('/api/groups/controller?op=getIds', {
names: Object.keys(existingGroupUsers), names: Object.keys(allExistingUsers),
userEmails: Object.values(existingGroupUsers).flat().map(user => user.email) userEmails: Object.values(allExistingUsers).flat().map(user => user.email)
}).then(response => response.data); });
const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => { const existingGroupsWithIds = Object.entries(allExistingUsers).reduce((acc, [groupName, users]) => {
acc[groupNameToId[groupName]] = users; const groupId = groupNameToId[groupName];
if (groupId) {
acc[groupId] = 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]);
@@ -458,6 +496,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
]); ]);
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,7 +308,7 @@ 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) => ({
@@ -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)

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,8 +19,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
else if (req.method === 'POST') { else if (req.method === 'POST') {
switch (op) { switch (op) {
case 'crossRefOwnership': case 'existantGroupIds':
res.status(200).json(await crossRefOwnership(req.body)); res.status(200).json(await existantGroupIds(req.body.names));
break; break;
case 'getIds': case 'getIds':
res.status(200).json(await getIds(req.body)); res.status(200).json(await getIds(req.body));
@@ -37,45 +37,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
} }
async function crossRefOwnership(body: any): Promise<string[]> {
const { userId, classrooms, entity } = body;
const existingClassrooms = await db.collection('groups')
.find({
name: { $in: classrooms },
admin: { $ne: userId }
})
.project({ name: 1, admin: 1, _id: 0 })
.toArray();
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<Record<string, string>> { async function getIds(body: any): Promise<Record<string, string>> {
const { names, userEmails } = body; const { names, userEmails } = body;
@@ -104,7 +65,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 +83,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

@@ -169,7 +169,11 @@ async function entityCheck(body: Record<string, any>): Promise<Array<{
} }
async function crossRefClassrooms(body: any) { async function crossRefClassrooms(body: any) {
const { sets, entity } = body as { sets: { email: string, classroom: string }[], entity: string }; const { sets, entity, userId } = body as {
sets: { email: string, classroom: string }[],
entity: string,
userId: string
};
const pipeline = [ const pipeline = [
// Match users with the provided emails // Match users with the provided emails
@@ -189,6 +193,8 @@ async function crossRefClassrooms(body: any) {
$expr: { $expr: {
$and: [ $and: [
{ $in: ['$$userId', '$participants'] }, { $in: ['$$userId', '$participants'] },
{ $eq: ['$admin', userId] },
{ $eq: ['$entity', entity] },
{ {
$let: { $let: {
vars: { vars: {
@@ -210,38 +216,6 @@ async function crossRefClassrooms(body: any) {
] ]
} }
} }
},
// 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' as: 'matchingGroups'

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">