Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.

Commit 7af9e34

Browse files
authored
Merge pull request #209 from ufabc-next/components-auto-sync
feat: sync automatically
2 parents fabf340 + a0d17bb commit 7af9e34

4 files changed

Lines changed: 68 additions & 240 deletions

File tree

apps/core/src/jobs/components-create.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,50 @@
1+
import type { Types } from 'mongoose';
2+
13
import { defineJob } from '@next/queues/client';
24

35
import { UfabcParserConnector } from '@/connectors/ufabc-parser.js';
46
import { JOB_NAMES } from '@/constants.js';
57
import { ComponentModel, type Component } from '@/models/Component.js';
8+
import { TeacherModel } from '@/models/Teacher.js';
69

710
import { findOrCreateSubject } from './utils/subject-resolution.js';
811

912
const connector = new UfabcParserConnector();
1013

14+
const teacherCache = new Map<string, Types.ObjectId | null>();
15+
16+
async function findTeacher(
17+
name: string | null
18+
): Promise<Types.ObjectId | null> {
19+
if (!name) return null;
20+
21+
const normalizedName = name
22+
.toLowerCase()
23+
.normalize('NFD')
24+
.replace(/[\u0300-\u036f]/g, '');
25+
26+
if (teacherCache.has(normalizedName)) {
27+
return teacherCache.get(normalizedName)!;
28+
}
29+
30+
const teacher = await TeacherModel.findByFuzzName(normalizedName);
31+
32+
if (!teacher && normalizedName !== '0') {
33+
teacherCache.set(normalizedName, null);
34+
return null;
35+
}
36+
37+
if (teacher && !teacher.alias.includes(normalizedName)) {
38+
await TeacherModel.findByIdAndUpdate(teacher._id, {
39+
$addToSet: { alias: [normalizedName, name.toLowerCase()] },
40+
});
41+
}
42+
43+
const teacherId = teacher?._id ?? null;
44+
teacherCache.set(normalizedName, teacherId);
45+
return teacherId;
46+
}
47+
1148
export const createComponentJob = defineJob(JOB_NAMES.CREATE_COMPONENT).handler(
1249
async ({ job }) => {
1350
const { componentId } = job.data;
@@ -24,6 +61,18 @@ export const createComponentJob = defineJob(JOB_NAMES.CREATE_COMPONENT).handler(
2461
subjectCode
2562
);
2663

64+
const professorTeacher = component.teachers?.find(
65+
(t) => t.role === 'professor' && !t.isSecondary
66+
);
67+
const practiceTeacher = component.teachers?.find(
68+
(t) => t.role === 'practice' && !t.isSecondary
69+
);
70+
71+
const [teoria, pratica] = await Promise.all([
72+
findTeacher(professorTeacher?.name ?? null),
73+
findTeacher(practiceTeacher?.name ?? null),
74+
]);
75+
2776
const dbComponent = {
2877
after_kick: [],
2978
before_kick: [],
@@ -33,7 +82,10 @@ export const createComponentJob = defineJob(JOB_NAMES.CREATE_COMPONENT).handler(
3382
turno: component.shift === 'morning' ? 'diurno' : 'noturno',
3483
turma: component.componentClass,
3584
vagas: component.vacancies,
36-
obrigatorias: component.courses?.filter((c) => c.category === 'mandatory').map((c) => c.UFCourseId) ?? [],
85+
obrigatorias:
86+
component.courses
87+
?.filter((c) => c.category === 'mandatory')
88+
.map((c) => c.UFCourseId) ?? [],
3789
uf_cod_turma: component.ufClassroomCode,
3890
campus: component.campus,
3991
codigo: component.ufComponentCode,
@@ -48,6 +100,8 @@ export const createComponentJob = defineJob(JOB_NAMES.CREATE_COMPONENT).handler(
48100
year: Number(component.season.split(':')[0]),
49101
quad: Number(component.season.split(':')[1]),
50102
season: component.season,
103+
teoria,
104+
pratica,
51105
} satisfies Omit<Component, 'createdAt' | 'updatedAt'>;
52106

53107
const createdComponent = await ComponentModel.findOneAndUpdate(

apps/core/src/routes/entities/components/index.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,10 @@ const plugin: FastifyPluginAsyncZodOpenApi = async (app) => {
258258
);
259259

260260
if (result.matchedCount === 0) {
261-
app.log.info({ disciplinaId, season: seasonToUse }, 'No matching component found');
261+
app.log.info(
262+
{ disciplinaId, season: seasonToUse },
263+
'No matching component found'
264+
);
262265
return reply.status(404).send({
263266
error: 'No matching component found',
264267
disciplinaId,
@@ -267,7 +270,10 @@ const plugin: FastifyPluginAsyncZodOpenApi = async (app) => {
267270
}
268271

269272
if (result.modifiedCount === 0) {
270-
app.log.info({ disciplinaId, season: seasonToUse }, 'Component found but not modified (same groupURL)');
273+
app.log.info(
274+
{ disciplinaId, season: seasonToUse },
275+
'Component found but not modified (same groupURL)'
276+
);
271277
return reply.send({
272278
message: 'Component found but groupURL was already set to this value',
273279
disciplinaId,
@@ -277,7 +283,11 @@ const plugin: FastifyPluginAsyncZodOpenApi = async (app) => {
277283
}
278284

279285
app.log.info(
280-
{ disciplinaId, season: seasonToUse, modifiedCount: result.modifiedCount },
286+
{
287+
disciplinaId,
288+
season: seasonToUse,
289+
modifiedCount: result.modifiedCount,
290+
},
281291
'GroupURL updated successfully'
282292
);
283293

apps/core/src/routes/sync/index.ts

Lines changed: 0 additions & 224 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ import { createHash } from 'node:crypto';
44

55
import { UfabcParserConnector } from '@/connectors/ufabc-parser.js';
66
import { ComponentModel, type Component } from '@/models/Component.js';
7-
import { SubjectModel } from '@/models/Subject.js';
8-
import { TeacherModel } from '@/models/Teacher.js';
9-
import { syncComponentsSchema } from '@/schemas/sync/components.js';
107
import { syncEnrolledSchema } from '@/schemas/sync/enrolled.js';
118
import { syncEnrollmentsSchema } from '@/schemas/sync/enrollments.js';
129

@@ -170,227 +167,6 @@ const plugin: FastifyPluginAsyncZodOpenApi = async (app) => {
170167
}
171168
);
172169

173-
app.put(
174-
'/components',
175-
{
176-
schema: syncComponentsSchema,
177-
preHandler: (request, reply) => request.isAdmin(reply),
178-
},
179-
async (request, reply) => {
180-
const { season, hash, ignoreErrors } = request.body;
181-
const componentsWithTeachers = await connector.getComponentsV2(season);
182-
183-
const teacherCache = new Map();
184-
const errors: Array<SyncError> = [];
185-
186-
const findTeacher = async (name: string | null) => {
187-
if (!name) return null;
188-
const normalizedName = name
189-
.toLowerCase()
190-
.normalize('NFD')
191-
.replace(/\u0300-\u036f/g, '');
192-
if (teacherCache.has(normalizedName))
193-
return teacherCache.get(normalizedName);
194-
// @ts-ignore Complex Type Mismatch
195-
const teacher = await TeacherModel.findByFuzzName(normalizedName);
196-
if (!teacher && normalizedName !== '0') {
197-
app.log.warn({
198-
originalName: name,
199-
normalizedName,
200-
}, 'Teacher not found');
201-
errors.push({
202-
original: name,
203-
parserError: ['Teacher not found in database'],
204-
metadata: { normalizedName },
205-
type: 'TEACHER_NOT_FOUND',
206-
});
207-
teacherCache.set(normalizedName, null);
208-
return null;
209-
}
210-
if (teacher && !teacher.alias.includes(normalizedName)) {
211-
await TeacherModel.findByIdAndUpdate(teacher._id, {
212-
$addToSet: { alias: [normalizedName, name.toLowerCase()] },
213-
});
214-
}
215-
teacherCache.set(normalizedName, teacher?._id ?? null);
216-
return teacher?._id ?? null;
217-
};
218-
219-
const components = await Promise.all(
220-
componentsWithTeachers.map(async (c) => {
221-
const professorTeacher = c.teachers.find(
222-
(t) => t.role === 'professor' && !t.isSecondary
223-
);
224-
const practiceTeacher = c.teachers.find(
225-
(t) => t.role === 'practice' && !t.isSecondary
226-
);
227-
228-
const [teoria, pratica] = await Promise.all([
229-
findTeacher(professorTeacher?.name ?? null),
230-
findTeacher(practiceTeacher?.name ?? null),
231-
]);
232-
const dbComponent = await ComponentModel.findOne({
233-
season,
234-
$or: [
235-
{ uf_cod_turma: c.ufClassroomCode.toUpperCase() },
236-
{ uf_cod_turma: c.ufClassroomCode },
237-
],
238-
}).lean();
239-
let subjectId = null;
240-
if (!dbComponent) {
241-
app.log.warn({
242-
msg: 'Component not found in database',
243-
uf_cod_turma: c.ufClassroomCode,
244-
});
245-
errors.push({
246-
original: c.ufClassroomCode,
247-
parserError: ['Component not found in database'],
248-
type: 'MATCHING_FAILED',
249-
});
250-
// Normalize the subject code (strip year, uppercase, etc.)
251-
const codeMatch = c.ufComponentCode.match(/^(.*?)-\d{2}$/);
252-
const normalizedCode = codeMatch ? codeMatch[1] : c.ufComponentCode;
253-
const subject = await SubjectModel.findOne({
254-
uf_subject_code: { $in: [normalizedCode] },
255-
}).lean();
256-
if (!subject) {
257-
app.log.warn({
258-
msg: 'Subject not found for unmatched component',
259-
component: c,
260-
});
261-
errors.push({
262-
original: c.name,
263-
parserError: ['Subject not found for unmatched component'],
264-
type: 'MATCHING_FAILED',
265-
});
266-
}
267-
subjectId = subject?._id;
268-
return {
269-
disciplina_id: null,
270-
campus: c.campus,
271-
disciplina: c.name,
272-
turno: c.shift,
273-
turma: c.componentClass,
274-
uf_cod_turma: c.ufClassroomCode,
275-
year: Number(season.split(':')[0]),
276-
quad: Number(season.split(':')[1]),
277-
codigo: c.ufComponentCode,
278-
season,
279-
teoria,
280-
pratica,
281-
subject: subjectId,
282-
after_kick: [],
283-
before_kick: [],
284-
alunos_matriculados: [],
285-
obrigatorias: [],
286-
ideal_quad: false,
287-
vagas: c.vacancies,
288-
kind: 'file' as 'file' | 'api',
289-
flag: 'upsert',
290-
ignoreErrors,
291-
};
292-
}
293-
return {
294-
...dbComponent,
295-
campus: c.campus,
296-
disciplina: c.name,
297-
turno: c.shift,
298-
turma: c.componentClass,
299-
uf_cod_turma: c.ufClassroomCode,
300-
teoria,
301-
pratica,
302-
ignoreErrors,
303-
};
304-
})
305-
);
306-
307-
if (!ignoreErrors && errors.length > 0) {
308-
const teacherErrors = errors.filter(
309-
(e) => e.type === 'TEACHER_NOT_FOUND'
310-
);
311-
const matchingErrors = errors.filter(
312-
(e) => e.type === 'MATCHING_FAILED'
313-
);
314-
return reply.status(403).send({
315-
msg: 'Errors found while verifying components',
316-
errors: {
317-
missingTeachers: [...new Set(teacherErrors.map((e) => e.original))],
318-
unmatchedComponents: matchingErrors,
319-
},
320-
totalErrors: errors.length,
321-
breakdown: {
322-
teacherErrors: teacherErrors.length,
323-
matchingErrors: matchingErrors.length,
324-
},
325-
});
326-
}
327-
328-
const componentHash = createHash('md5')
329-
.update(JSON.stringify(components))
330-
.digest('hex');
331-
if (componentHash !== hash) {
332-
return {
333-
hash: componentHash,
334-
errors,
335-
total: components.length,
336-
payload: components,
337-
};
338-
}
339-
340-
// Dispatch all jobs, always passing flag and ignoreErrors
341-
const dispatchPromises = components.map(async (componentData) => {
342-
try {
343-
if (!componentData.subject) {
344-
request.log.warn({
345-
error: 'Component data is missing',
346-
component: componentData.disciplina,
347-
msg: 'Component data is missing',
348-
});
349-
throw new Error('Component data is missing');
350-
}
351-
352-
// fuck this
353-
// @ts-expect-error
354-
await app.job.dispatch('ComponentsTeachersSync', componentData);
355-
return { success: true, component: componentData.disciplina };
356-
} catch (error) {
357-
const errorMsg = `Failed to dispatch component: ${componentData.disciplina}`;
358-
if (ignoreErrors) {
359-
request.log.warn({
360-
error: error instanceof Error ? error.message : String(error),
361-
component: componentData.disciplina,
362-
msg: `${errorMsg} (ignored)`,
363-
});
364-
return {
365-
success: false,
366-
component: componentData.disciplina,
367-
ignored: true,
368-
};
369-
}
370-
request.log.error({
371-
error: error instanceof Error ? error.message : String(error),
372-
component: componentData.disciplina,
373-
msg: errorMsg,
374-
});
375-
throw error;
376-
}
377-
});
378-
const dispatchResults = await Promise.all(dispatchPromises);
379-
const successfulDispatches = dispatchResults.filter(
380-
(r) => r.success
381-
).length;
382-
const ignoredErrors = dispatchResults.filter(
383-
(r) => !r.success && r.ignored
384-
).length;
385-
return reply.send({
386-
dispatched: true,
387-
msg: 'Component teacher sync jobs dispatched',
388-
totalComponents: components.length,
389-
successfulDispatches,
390-
ignoredErrors: ignoreErrors ? ignoredErrors : 0,
391-
});
392-
}
393-
);
394170

395171
app.put(
396172
'/enrolled',

apps/core/src/schemas/sync/components.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)