[Backend][Course search] make course search use new course units and substitutions

This commit is contained in:
joonas-a
2026-09-21 15:24:21 +03:00
committed by Aaro Koivukangas
parent 5bd7fb6792
commit c908a61c0f
8 changed files with 124 additions and 95 deletions
+3 -21
View File
@@ -9,7 +9,6 @@ import {
CourseYearlyStatsReqBody,
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { getCourseDetails, getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import {
@@ -24,30 +23,13 @@ const router = Router()
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/coursesmulti',
async (req, res) => {
const { name, code, includeSpecial } = req.query
const { name, code } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
const mergedCourses = new Map<string, CourseWithSubsDetails>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
const mergedCourse = mergedCourses.get(course.code)!
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
res.json({ courses: Array.from(mergedCourses.values()) })
const courses = await getCoursesByNameAndOrCode(name, code)
res.json({ courses }).end()
}
)
@@ -1,70 +1,115 @@
import { Op } from 'sequelize'
import { col, fn, Op, QueryTypes } from 'sequelize'
import { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { CourseModel } from '../../models'
import { dbConnections } from '../../database/connection'
const nameLikeTerm = (name: string) => {
if (!name) {
return undefined
}
const term = `%${name.trim()}%`
return {
name: {
[Op.or]: {
fi: {
[Op.iLike]: term,
const likeTerm = (userInput: string, type: 'name' | 'code') => {
const searchTerm = userInput.trim().replace(/[\\%_]/g, '')
if (!searchTerm) return undefined
return type === 'name'
? {
name: {
[Op.or]: {
fi: {
[Op.iLike]: `%${searchTerm}%`,
},
sv: {
[Op.iLike]: `%${searchTerm}%`,
},
en: {
[Op.iLike]: `%${searchTerm}%`,
},
},
},
sv: {
[Op.iLike]: term,
},
en: {
[Op.iLike]: term,
}
: {
code: {
[Op.iLike]: `${searchTerm}%`,
},
}
}
export const getCoursesByNameAndOrCode = async (name: string, code: string): Promise<CourseWithSubsDetails[]> => {
// Get groupIds for the course units (CU) the user is querying
// This step allows users to search with old or upcoming course codes and names
// Example: searching for GEOK_2011 (old code) returns results for GEOK2011 (new code)
const groupIds = (
await CourseModel.findAll({
attributes: [[fn('DISTINCT', col('group_id')), 'groupId']],
where: {
...likeTerm(name, 'name'),
...likeTerm(code, 'code'),
groupId: { [Op.ne]: null },
},
},
}
}
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const codeLikeTerm = (code: string) => {
if (!code) {
return undefined
}
return {
code: {
[Op.iRegexp]: `^(AY|A)?${escapeRegExp(code)}`,
},
}
}
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
const courses = await CourseModel.findAll({
where: {
...(includeSpecial ? {} : { mainCourseCode: { [Op.ne]: null } }),
...nameLikeTerm(name),
...codeLikeTerm(code),
},
raw: true,
})
const coursesWithSubstitutionGroupDetails = await Promise.all(
courses.map<Promise<CourseWithSubsDetails>>(async course => {
const groupDetails = await Promise.all(
course?.substitution_groups?.map(async group => {
return await CourseModel.findAll({
attributes: ['name', 'code'],
where: { code: { [Op.in]: group } },
raw: true,
})
})
)
return { ...course, substitution_groups: groupDetails }
raw: true,
})
).map(({ groupId }) => groupId)
if (!groupIds.length) return []
// Gets name/code from the "primary" CU, and attainment dates from all CUs with the same groupId
const primaryCourses = await dbConnections.sequelize.query<CourseModel>(
`
WITH filtered AS (
SELECT *
FROM course
WHERE group_id IN (:groupIds)
),
grouped AS (
SELECT
group_id,
MIN(min_attainment_date) AS min_attainment_date,
MAX(max_attainment_date) AS max_attainment_date
FROM filtered
GROUP BY group_id
),
primary_course AS (
SELECT code, name, group_id, substitution_groups
FROM filtered
where is_primary = true
)
SELECT
g.group_id AS "groupId",
g.min_attainment_date AS "minAttainmentDate",
g.max_attainment_date AS "maxAttainmentDate",
p.name,
p.code,
p.substitution_groups AS "substitutionGroups"
FROM grouped g
JOIN primary_course p
ON p.group_id = g.group_id
`,
{
replacements: {
groupIds,
},
type: QueryTypes.SELECT,
}
)
return coursesWithSubstitutionGroupDetails
const substitutionGroupIds = [...new Set(primaryCourses.map(course => course.substitutionGroups).flat(2))]
const substitutionCourses = substitutionGroupIds.length
? Object.groupBy(
await CourseModel.findAll({
attributes: ['name', 'groupId', 'code'],
where: { groupId: { [Op.in]: substitutionGroupIds }, isPrimary: true },
raw: true,
}),
({ groupId }) => groupId
)
: {}
const coursesWithSubstitutionDetails = primaryCourses.map(course => ({
...course,
// NOTE: There are some substitutions that just don't exist in importer -> don't send undefined to client
substitutionGroups: course.substitutionGroups
.map(groupIds => groupIds.flatMap(groupId => substitutionCourses[groupId]).filter(sub => !!sub))
.filter(group => group.length),
}))
return coursesWithSubstitutionDetails
}
export const getCoursesByCodes = (codes: string[]) => {
@@ -79,7 +79,7 @@ export const getOptionsForStudents = (
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
CourseModel.findAll({
attributes: ['code', 'name', 'substitution_groups', 'is_study_module'],
attributes: ['code', 'name', 'substitutionGroups', 'isStudyModule'],
where: {
code: { [Op.in]: courses },
},
@@ -39,8 +39,8 @@ const CourseRow = ({
data-cy={`course-${course.code}`}
hover
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
onClick={() => (course.minAttainmentDate ? onSelectCourse(course) : null)}
style={{ cursor: course.minAttainmentDate ? 'pointer' : 'default' }}
>
<TableCell>
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
@@ -54,7 +54,7 @@ const CourseRow = ({
<TableCell>
{combineSubstitutions ? (
<Stack>
{course?.substitution_groups.map(group => (
{course?.substitutionGroups.map(group => (
<GroupChip getTextIn={getTextIn} group={group} key={group.map(({ code }) => code).join(':')} />
)) ?? <Typography fontSize="0.9rem">Equivalent groups not available!</Typography>}
</Stack>
@@ -135,12 +135,12 @@ export const SearchForm = () => {
const courses = matchingCourses
.filter(course => !selectedCourses[course.code])
.sort((a, b) => {
const yearA = dayjs(a.max_attainment_date).year()
const yearB = dayjs(b.max_attainment_date).year()
const yearA = dayjs(a.maxAttainmentDate).year()
const yearB = dayjs(b.maxAttainmentDate).year()
if (yearA !== yearB) return yearB - yearA
const isFallA = dayjs(a.max_attainment_date).month() >= 7 ? 1 : 0
const isFallB = dayjs(b.max_attainment_date).month() >= 7 ? 1 : 0
const isFallA = dayjs(a.maxAttainmentDate).month() >= 7 ? 1 : 0
const isFallB = dayjs(b.maxAttainmentDate).month() >= 7 ? 1 : 0
if (isFallA !== isFallB) return isFallB - isFallA
return a.code.localeCompare(b.code)
@@ -1,4 +1,5 @@
import type { CourseSearchState } from '@/pages/CourseStatistics'
import { SearchResultCourse } from '@/types/api/courses'
import { AvailableStats, CourseStat, Realisation } from '@/types/courseStat'
import { Name } from '@oodikone/shared/types'
import { isSpring } from '@oodikone/shared/util'
@@ -35,16 +36,16 @@ const MAX_YEAR = 2112
const isPre2016Course = course => !Number.isNaN(Number(course.code.charAt(0)))
const getYearText = (year: number, spring: boolean) => (spring ? `Spring ${year}` : `Fall ${year}`)
export const getActiveYears = course => {
if (!course.min_attainment_date && !course.max_attainment_date) return 'No attainments yet'
export const getActiveYears = (course: SearchResultCourse) => {
if (!(course.minAttainmentDate && course.maxAttainmentDate)) return 'No attainments yet'
const min_attainment_date = new Date(course.min_attainment_date)
const max_attainment_date = new Date(course.max_attainment_date)
const minAttainmentDate = new Date(course.minAttainmentDate)
const maxAttainmentDate = new Date(course.maxAttainmentDate)
const [startYear, endYear] = [min_attainment_date.getFullYear(), max_attainment_date.getFullYear()]
const [startYear, endYear] = [minAttainmentDate.getFullYear(), maxAttainmentDate.getFullYear()]
const startYearText = getYearText(startYear, isSpring(min_attainment_date))
const endYearText = getYearText(endYear, isSpring(max_attainment_date))
const startYearText = getYearText(startYear, isSpring(minAttainmentDate))
const endYearText = getYearText(endYear, isSpring(maxAttainmentDate))
if (endYear === MAX_YEAR && isPre2016Course(course)) return `— ${getYearText(2016, false)}`
else if (startYear === MIN_YEAR) return `— ${endYearText}`
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../types'
import { FormattedStudent } from '../types/studentData'
export type CourseStats = Pick<Course, 'code' | 'name' | 'is_study_module' | 'substitutions' | 'substitution_groups'>
export type CourseStats = Pick<Course, 'code' | 'name' | 'isStudyModule' | 'substitutionGroups'>
export type AttainmentDates = {
latestTotal?: Date
+2 -1
View File
@@ -1,3 +1,4 @@
import { Course } from '../models'
export type CourseWithSubsDetails = Omit<Course, 'substitution_groups'> & { substitution_groups: Course[][] }
type CourseSubstitution = Pick<Course, 'code' | 'groupId' | 'name'>
export type CourseWithSubsDetails = Omit<Course, 'substitutionGroups'> & { substitutionGroups: CourseSubstitution[][] }