diff --git a/apps/dashboard/src/app/(internal)/arrangementer/components/qr-code-scanned-modal.tsx b/apps/dashboard/src/app/(internal)/arrangementer/components/qr-code-scanned-modal.tsx index d9fea68544..c31361a21b 100644 --- a/apps/dashboard/src/app/(internal)/arrangementer/components/qr-code-scanned-modal.tsx +++ b/apps/dashboard/src/app/(internal)/arrangementer/components/qr-code-scanned-modal.tsx @@ -5,10 +5,10 @@ import { type User, findActiveMembership, getAttendeeQueuePosition, - getMembershipGrade, getUnreservedAttendeeCount, + getMembershipTypeName, } from "@dotkomonline/types" -import { getCurrentUTC } from "@dotkomonline/utils" +import { getCurrentUTC, getStudyGrade } from "@dotkomonline/utils" import { Button, Flex, Group, Image, Stack, Text, Title, useComputedColorScheme } from "@mantine/core" import { useMediaQuery } from "@mantine/hooks" import { type ContextModalProps, modals } from "@mantine/modals" @@ -167,7 +167,8 @@ const UserBox = ({ user, isMobile }: UserBoxProps) => { const isLightMode = useComputedColorScheme() === "light" const membership = findActiveMembership(user) - const grade = membership ? getMembershipGrade(membership) : null + const grade = membership?.semester != null ? getStudyGrade(membership.semester) : null + const membershipType = membership && getMembershipTypeName(membership.type) return ( @@ -183,7 +184,8 @@ const UserBox = ({ user, isMobile }: UserBoxProps) => { {user.name {user.name} - Klasse: {grade} + {membership ? `Medlemskap: ${membershipType}` : "Ingen aktivt medlemskap"} + {membership ? (grade ? `Klasse: ${grade}` : "Ingen klassetrinn") : "-"} Kjønn: {user.gender || "Ikke oppgitt"} Kostholdsrestriksjoner: {user.dietaryRestrictions || "Ingen"} diff --git a/apps/dashboard/src/app/(internal)/brukere/[id]/edit-card.tsx b/apps/dashboard/src/app/(internal)/brukere/[id]/edit-card.tsx index 4669a45412..9f211a6454 100644 --- a/apps/dashboard/src/app/(internal)/brukere/[id]/edit-card.tsx +++ b/apps/dashboard/src/app/(internal)/brukere/[id]/edit-card.tsx @@ -1,12 +1,6 @@ import { env } from "@/lib/env" import { useSession } from "@dotkomonline/oauth2/react" -import { - UserWriteSchema, - type WorkspaceUser, - findActiveMembership, - getMembershipGrade, - getMembershipTypeName, -} from "@dotkomonline/types" +import { UserWriteSchema, type WorkspaceUser, findActiveMembership, getMembershipTypeName } from "@dotkomonline/types" import { Button, Group, Loader, Stack, Text, TextInput, Title } from "@mantine/core" import { useDebouncedValue } from "@mantine/hooks" import { IconCheck, IconLink, IconUsersGroup, IconX, IconArrowUpRight } from "@tabler/icons-react" @@ -15,6 +9,7 @@ import { useLinkOwUserToWorkspaceUserMutation, useUpdateUserMutation } from "../ import { useFindWorkspaceUserQuery, useGroupAllByMemberQuery, useIsAdminQuery } from "../queries" import { useUserProfileEditForm } from "./edit-form" import { useUserDetailsContext } from "./provider" +import { getStudyGrade } from "@dotkomonline/utils" export const UserEditCard: FC = () => { const session = useSession() @@ -57,7 +52,7 @@ export const UserEditCard: FC = () => { }) const activeMembership = findActiveMembership(user) - const grade = activeMembership ? getMembershipGrade(activeMembership) : null + const grade = activeMembership?.semester != null ? getStudyGrade(activeMembership.semester) : null const membershipType = activeMembership ? getMembershipTypeName(activeMembership.type) : null return ( diff --git a/apps/dashboard/src/app/(internal)/brukere/components/create-membership-modal.tsx b/apps/dashboard/src/app/(internal)/brukere/components/create-membership-modal.tsx index 3b2a38da9f..03c84a01a6 100644 --- a/apps/dashboard/src/app/(internal)/brukere/components/create-membership-modal.tsx +++ b/apps/dashboard/src/app/(internal)/brukere/components/create-membership-modal.tsx @@ -3,6 +3,7 @@ import { type ContextModalProps, modals } from "@mantine/modals" import type { FC } from "react" import { useCreateMembershipMutation } from "../mutations" import { useMembershipWriteForm } from "./membership-form" +import { Stack, Text } from "@mantine/core" export const CreateMembershipModal: FC> = ({ context, id, innerProps: { user } }) => { const close = () => context.closeModal(id) @@ -16,7 +17,14 @@ export const CreateMembershipModal: FC> = ({ c close() }, }) - return + return ( + + + Rediger semesterverdien for å endre årstrinn. + + + + ) } export const useCreateMembershipModal = diff --git a/apps/dashboard/src/app/(internal)/brukere/components/edit-membership-modal.tsx b/apps/dashboard/src/app/(internal)/brukere/components/edit-membership-modal.tsx index 872c3fcaba..d4a4ca23ff 100644 --- a/apps/dashboard/src/app/(internal)/brukere/components/edit-membership-modal.tsx +++ b/apps/dashboard/src/app/(internal)/brukere/components/edit-membership-modal.tsx @@ -3,6 +3,7 @@ import { type ContextModalProps, modals } from "@mantine/modals" import type { FC } from "react" import { useUpdateMembershipMutation } from "../mutations" import { useMembershipWriteForm } from "./membership-form" +import { Stack, Text } from "@mantine/core" export const EditMembershipModal: FC> = ({ context, @@ -21,7 +22,14 @@ export const EditMembershipModal: FC + return ( + + + Rediger semesterverdien for å endre årstrinn. + + + + ) } export const useEditMembershipModal = diff --git a/apps/dashboard/src/app/(internal)/brukere/components/membership-form.tsx b/apps/dashboard/src/app/(internal)/brukere/components/membership-form.tsx index 7ad8f3e142..6b79b90303 100644 --- a/apps/dashboard/src/app/(internal)/brukere/components/membership-form.tsx +++ b/apps/dashboard/src/app/(internal)/brukere/components/membership-form.tsx @@ -1,4 +1,3 @@ -import { createDateTimeInput } from "@/components/forms/DateTimeInput" import { useFormBuilder } from "@/components/forms/Form" import { createSelectInput } from "@/components/forms/SelectInput" import { @@ -9,30 +8,113 @@ import { getMembershipTypeName, getSpecializationName, } from "@dotkomonline/types" -import { getCurrentUTC } from "@dotkomonline/utils" -import { addYears, isBefore } from "date-fns" +import { + getCurrentSemesterStart, + getNextSemesterStart, + getStudyGrade, + getCurrentUTC, + getPreviousSemesterStart, + isSpringSemester, +} from "@dotkomonline/utils" +import { isBefore, roundToNearestHours } from "date-fns" import type { z } from "zod" +import { ActionIcon, Button, Group, NumberInput, Stack } from "@mantine/core" +import { Controller } from "react-hook-form" +import { ErrorMessage } from "@hookform/error-message" +import { DatePickerInput } from "@mantine/dates" +import { IconArrowLeft, IconArrowRight, IconX } from "@tabler/icons-react" + +const BACHELOR_SEMESTERS = 6 +const MASTER_SEMESTER_OFFSET = BACHELOR_SEMESTERS +const MASTER_SEMESTERS = 4 export const MembershipWriteFormSchema = MembershipWriteSchema.superRefine((data, ctx) => { - if (isBefore(data.end, data.start)) { + if (data.end !== null && isBefore(data.end, data.start)) { ctx.addIssue({ code: "custom", - message: "Sluttdato må være etter startdato", + message: "Sluttdato må være etter startdato.", path: ["end"], }) } + + if (data.end === null && data.type !== "KNIGHT") { + ctx.addIssue({ + code: "custom", + message: "Sluttdato må oppgis for ikke-Ridder-medlemskap.", + path: ["end"], + }) + } + + if (data.end !== null && data.type === "KNIGHT") { + ctx.addIssue({ + code: "custom", + message: "Riddermedlemskap skal ikke ha sluttdato.", + path: ["end"], + }) + } + + if (data.type === "MASTER_STUDENT") { + if (data.specialization === null) { + ctx.addIssue({ + code: "custom", + message: "Spesialisering må oppgis for mastermedlemskap.", + path: ["specialization"], + }) + } + + if ( + data.semester === null || + data.semester < MASTER_SEMESTER_OFFSET || + data.semester >= MASTER_SEMESTER_OFFSET + MASTER_SEMESTERS + ) { + ctx.addIssue({ + code: "custom", + message: `Semester må være oppgitt og minst ${MASTER_SEMESTER_OFFSET + 1} og maks ${MASTER_SEMESTER_OFFSET + MASTER_SEMESTERS} for mastermedlemskap.`, + path: ["semester"], + }) + } + } + + if (data.specialization !== null && data.type !== "MASTER_STUDENT") { + ctx.addIssue({ + code: "custom", + message: "Spesialisering kan kun oppgis for mastermedlemskap.", + path: ["specialization"], + }) + } + + if (data.type === "BACHELOR_STUDENT") { + if (data.semester === null || data.semester < 0 || data.semester >= BACHELOR_SEMESTERS) { + ctx.addIssue({ + code: "custom", + message: `Semester må være oppgitt og minst 1 og maks ${BACHELOR_SEMESTERS} for bachelormedlemskap.`, + path: ["semester"], + }) + } + } + + if (data.type === "SOCIAL_MEMBER") { + if (data.semester === null || data.semester < 0 || data.semester >= BACHELOR_SEMESTERS + MASTER_SEMESTERS) { + ctx.addIssue({ + code: "custom", + message: `Semester må være oppgitt og minst 1 og maks ${BACHELOR_SEMESTERS + MASTER_SEMESTERS} for sosialmedlemskap.`, + path: ["semester"], + }) + } + } }) type MembershipWriteFormSchema = z.infer const DEFAULT_VALUES: Partial = { - start: getCurrentUTC(), - end: addYears(getCurrentUTC(), 1), + start: getCurrentSemesterStart(), + end: getNextSemesterStart(), specialization: null, + semester: 0, } interface UseMembershipWriteFormProps { - onSubmit(data: z.infer): void + onSubmit(data: MembershipWriteFormSchema): void defaultValues?: Partial label?: string } @@ -58,7 +140,7 @@ export const useMembershipWriteForm = ({ })), }), specialization: createSelectInput({ - label: "Spesialisering", + label: "Masterspesialisering", required: false, clearable: true, placeholder: "Velg spesialisering", @@ -68,15 +150,181 @@ export const useMembershipWriteForm = ({ value: specialization, label: getSpecializationName(specialization) ?? specialization, })), + disabled: false, }), - start: createDateTimeInput({ - label: "Startdato", - required: true, - }), - end: createDateTimeInput({ - label: "Sluttdato", - required: true, - }), + semester: ({ state, control }) => { + const name = "semester" + const label = "Semester" + + return ( + { + const zeroIndexedValue = field.value != null ? field.value : null + const oneIndexedValue = zeroIndexedValue != null ? zeroIndexedValue + 1 : null + const studyGrade = zeroIndexedValue != null ? getStudyGrade(zeroIndexedValue) : null + const isAutumnSemester = zeroIndexedValue != null ? zeroIndexedValue % 2 === 0 : null + + return ( + + { + const zeroIndexedValue = value !== undefined ? Number(value) - 1 : null + field.onChange(zeroIndexedValue) + }} + error={state.errors[name] && } + /> + + + ) + }} + /> + ) + }, + start: ({ state, control }) => { + const name = "start" + + return ( + ( + + } + required + /> + + + + + + )} + /> + ) + }, + end: ({ state, control }) => { + const name = "end" + + return ( + ( + + } + rightSection={ + field.onChange(null)}> + + + } + /> + + + + + + )} + /> + ) + }, }, }) } diff --git a/apps/dashboard/src/app/(internal)/brukere/components/use-membership-table.tsx b/apps/dashboard/src/app/(internal)/brukere/components/use-membership-table.tsx index 47933e1247..f5d8c725c1 100644 --- a/apps/dashboard/src/app/(internal)/brukere/components/use-membership-table.tsx +++ b/apps/dashboard/src/app/(internal)/brukere/components/use-membership-table.tsx @@ -9,6 +9,7 @@ import { useMemo } from "react" import { useIsAdminQuery } from "../queries" import { useConfirmDeleteMembershipModal } from "./confirm-delete-membership-modal" import { useEditMembershipModal } from "./edit-membership-modal" +import { getStudyGrade, isSpringSemester } from "@dotkomonline/utils" interface Props { data: Membership[] @@ -27,6 +28,29 @@ export const useMembershipTable = ({ data }: Props) => { header: () => "Type", cell: (info) => getMembershipTypeName(info.getValue()), }), + columnHelper.accessor("semester", { + header: () => "Semester", + cell: (info) => { + const zeroIndexSemester = info.getValue() + + if (zeroIndexSemester == null) { + return "-" + } + + const season = isSpringSemester(zeroIndexSemester) ? "våren" : "høsten" + const grade = getStudyGrade(zeroIndexSemester) + + return `${zeroIndexSemester + 1}. sem. (${season} ${grade}. år)` + }, + }), + columnHelper.accessor("specialization", { + header: () => "Spesialisering", + cell: (info) => { + const specialization = info.getValue() + + return specialization ? getSpecializationName(specialization) : "-" + }, + }), columnHelper.accessor("start", { header: () => "Startdato", cell: (info) => formatDate(info.getValue(), "dd.MM.yyyy"), @@ -43,21 +67,13 @@ export const useMembershipTable = ({ data }: Props) => { return formatDate(endDate, "dd.MM.yyyy") }, }), - columnHelper.accessor("specialization", { - header: () => "Spesialisering", - cell: (info) => { - const specialization = info.getValue() - - return specialization ? getSpecializationName(specialization) : "-" - }, - }), columnHelper.accessor((role) => role, { id: "actions", header: () => "Detaljer", cell: (info) => ( + {props.required !== true && ( + + )} )} /> diff --git a/apps/rpc/src/modules/event/attendance-service.ts b/apps/rpc/src/modules/event/attendance-service.ts index 06517662b3..0d604c1652 100644 --- a/apps/rpc/src/modules/event/attendance-service.ts +++ b/apps/rpc/src/modules/event/attendance-service.ts @@ -23,12 +23,18 @@ import { type User, type UserId, findActiveMembership, - getMembershipGrade, hasAttendeePaid, isAttendable, findFirstHostingGroupEmail, } from "@dotkomonline/types" -import { createAbsoluteEventPageUrl, createPoolName, getCurrentUTC, ogJoin, slugify } from "@dotkomonline/utils" +import { + createAbsoluteEventPageUrl, + createPoolName, + getCurrentUTC, + ogJoin, + slugify, + getStudyGrade, +} from "@dotkomonline/utils" import { addDays, addHours, @@ -656,6 +662,8 @@ export function getAttendanceService( (!isFuture(reservationActiveAt) && (pool.capacity === 0 || poolAttendees.length < pool.capacity)) || options.immediateReservation + const userGrade = membership.semester != null ? getStudyGrade(membership.semester) : null + const attendee = await attendanceRepository.createAttendee( handle, attendance.id, @@ -666,7 +674,7 @@ export function getAttendanceService( earliestReservationAt: reservationActiveAt, reserved: isImmediateReservation, selections: [], - userGrade: getMembershipGrade(membership), + userGrade, } satisfies AttendeeWrite) ) diff --git a/apps/rpc/src/modules/event/attendance.e2e-spec.ts b/apps/rpc/src/modules/event/attendance.e2e-spec.ts index 6ceeaa13d3..e27a216d63 100644 --- a/apps/rpc/src/modules/event/attendance.e2e-spec.ts +++ b/apps/rpc/src/modules/event/attendance.e2e-spec.ts @@ -5,7 +5,7 @@ import { type MembershipWrite, findActiveMembership, } from "@dotkomonline/types" -import { getCurrentUTC } from "@dotkomonline/utils" +import { getCurrentUTC, getCurrentSemesterStart, getNextSemesterStart, isSpringSemester } from "@dotkomonline/utils" import { faker } from "@faker-js/faker" import type { ApiResponse, GetUsers200ResponseOneOfInner } from "auth0" import { addDays, addHours, addMinutes, isFuture, subHours } from "date-fns" @@ -41,9 +41,10 @@ export function getMockAttendancePool(input: Partial = {}): export function getMockMembership(input: Partial = {}): MembershipWrite { return { type: "BACHELOR_STUDENT", - start: addDays(getCurrentUTC(), -100), - end: addDays(getCurrentUTC(), 100), + start: getCurrentSemesterStart(), + end: getNextSemesterStart(), specialization: null, + semester: isSpringSemester() ? 1 : 2, ...input, } } diff --git a/apps/rpc/src/modules/user/membership-service.ts b/apps/rpc/src/modules/user/membership-service.ts index 119e921c49..113a4b17c9 100644 --- a/apps/rpc/src/modules/user/membership-service.ts +++ b/apps/rpc/src/modules/user/membership-service.ts @@ -1,188 +1,288 @@ -import { getAcademicStart, getNextAcademicStart } from "@dotkomonline/types" -import { differenceInMonths, subYears } from "date-fns" +import { + getAutumnSemesterStart, + isSpringSemester, + getSpringSemesterStart, + getSemesterDifference, + isAutumnSemester, +} from "@dotkomonline/utils" import invariant from "tiny-invariant" import type { NTNUGroup } from "../feide/feide-groups-repository" import { getLogger } from "@dotkomonline/logger" export interface MembershipService { - findMasterStartYearDelta(courses: NTNUGroup[]): number - findBachelorStartYearDelta(courses: NTNUGroup[]): number + /** + * Find the approximate semester based on a student's courses against a hard-coded set of courses. + * + * NOTE: The value is 0-indexed. + * + * Master studies begin at semester 6. + * + * @see getStudyGrade(semester) in types package + * + * @example + * findEstimatedSemester(...) -> 0 // 1st semester Bachelor (Autumn) + * findEstimatedSemester(...) -> 1 // 2nd semester Bachelor (Spring) + * findEstimatedSemester(...) -> 2 // 3rd semester Bachelor + * findEstimatedSemester(...) -> 3 // 4th semester Bachelor + * findEstimatedSemester(...) -> 4 // 5th semester Bachelor + * findEstimatedSemester(...) -> 5 // 6th semester Bachelor + * findEstimatedSemester(...) -> 6 // 1st semester Master (regardless of prior Bachelor length) + * findEstimatedSemester(...) -> 7 // 2nd semester Master + * findEstimatedSemester(...) -> 8 // 3rd semester Master + * findEstimatedSemester(...) -> 9 // 4th semester Master + */ + findEstimatedSemester(study: "BACHELOR" | "MASTER", courses: ReadonlyArray): number } +// Semesters are 0-indexed in our calculations. The values for `minimumEnrolledCourses` are arbitrarily chosen by us. const BACHELOR_STUDY_PLAN = [ { semester: 0, - courses: ["IT2805", "MA0001", "TDT4109"], + courses: ["IT2805", "MA0001", "TDT4109", "EXPH0300"], + minimumEnrolledCourses: 3, }, { semester: 1, courses: ["MA0301", "TDT4100", "TDT4180", "TTM4100"], + minimumEnrolledCourses: 3, }, { semester: 2, courses: ["IT1901", "TDT4120", "TDT4160"], + minimumEnrolledCourses: 3, }, { semester: 3, courses: ["TDT4140", "TDT4145"], + minimumEnrolledCourses: 2, }, { semester: 4, - // Semester 1 in year 3 are all elective courses, so we do not use any of them to determine which year somebody - // started studying courses: [], + minimumEnrolledCourses: 0, }, { semester: 5, courses: ["IT2901"], + minimumEnrolledCourses: 1, }, ] as const +export const BACHELOR_FIRST_SEMESTER = BACHELOR_STUDY_PLAN.at(0)?.semester ?? 0 +export const BACHELOR_LAST_SEMESTER = BACHELOR_STUDY_PLAN.at(-1)?.semester ?? 5 + +// This value is defined so the Master semesters continue incrementally from the Bachelor semesters. This is not +// necessary (though they should never overlap with Bachelor semester values), but makes it easier for us mere humans to +// comprehend and easier for manual intervention. +export const MASTER_SEMESTER_OFFSET = BACHELOR_LAST_SEMESTER + 1 + const MASTER_STUDY_PLAN = [ { - semester: 0, + semester: MASTER_SEMESTER_OFFSET + 0, courses: [], + minimumEnrolledCourses: 0, }, { - semester: 1, + semester: MASTER_SEMESTER_OFFSET + 1, courses: [], + minimumEnrolledCourses: 0, }, { - semester: 2, + semester: MASTER_SEMESTER_OFFSET + 2, courses: ["IT3915"], + minimumEnrolledCourses: 1, }, { - semester: 3, + semester: MASTER_SEMESTER_OFFSET + 3, courses: ["IT3920"], + minimumEnrolledCourses: 1, }, ] as const +export const MASTER_FIRST_SEMESTER = MASTER_STUDY_PLAN.at(0)?.semester ?? 6 +export const MASTER_LAST_SEMESTER = MASTER_STUDY_PLAN.at(-1)?.semester ?? 9 + type StudyPlanCourseSet = typeof BACHELOR_STUDY_PLAN | typeof MASTER_STUDY_PLAN export function getMembershipService(): MembershipService { const logger = getLogger("membership-service") - // The study plan course set makes some assumptions for the approximation code to work as expected. In order to make - // it easier for future dotkom developers, the invariants are checked here. - validateStudyPlanCourseSet(BACHELOR_STUDY_PLAN) - validateStudyPlanCourseSet(MASTER_STUDY_PLAN) - - function validateStudyPlanCourseSet(courseSet: StudyPlanCourseSet) { - // If three semesters in a row do not have mandatory courses, the distance check will not be able to distinguish - // between a first and second-grader, assuming semester [0, 1, 2] are all without mandatory courses. - let maxDistance = 0 - for (const semester of courseSet) { - if (semester.courses.length === 0) { - maxDistance += 1 - } else { - maxDistance = 0 - } - - if (maxDistance >= 3) { - throw new Error("A StudyPlanCourseSet may not have three semesters without mandatory courses in a row") - } - } - } /** - * Find the approximate start year based on a student's courses against a hard-coded set of courses. + * Find the approximate semester based on a student's courses against a hard-coded set of courses. */ - function findApproximateStartYear(studentCourses: NTNUGroup[], courseSet: StudyPlanCourseSet): number { - logger.info("Searching for applicable membership based on courses %o and study plan %o", studentCourses, courseSet) - let largestSemester = 0 + function estimateSemester( + courseSet: StudyPlanCourseSet, + studentCourses: ReadonlyArray, + semesterOffset: number + ): number { + logger.info("Searching for semester estimate based on courses %o and study plan %o", studentCourses, courseSet) + + // The current largest estimate is the starting value + let largestSemester = semesterOffset + // We need to keep track of if the previous semester was estimated, as we cannot use an estimated semester to + // estimate further. In other words, we can only estimate for one semester at a time. This means that if a user has + // been an exchange student for two semesters in a row, we would not be able to accurately assign them a semester. + // The administrators would need to be manually adjusted the value given from this function. + let isPreviousSemesterValueEstimated = false + for (let i = 0; i < courseSet.length; i++) { - const semester = courseSet[i] - - // Semesters with zero mandatory courses possibly increment `largestSemester` by determining the number of - // years since the last mandatory courses. However, if the current semester we are iterating over is the first - // in the study plan, we cannot get any more information that could be used to increment `largestSemester`, so - // we do not try. - const isFirstYear = i === 0 || i === 1 - - if (semester.courses.length === 0 && !isFirstYear) { - const previousSemesters = courseSet.slice(0, i).filter((semester) => semester.courses.length !== 0) - // By invariant that this is not the first year, and that there are maximum two semesters without mandatory - // courses, `previousSemesters` is guaranteed to have at least one element. - invariant(previousSemesters.length !== 0) - - // We take the ceil(mean(distances)) across all past semesters. - let largestLocalSemester = 0 - for (const previousSemester of previousSemesters.toReversed()) { - // The criteria here is the same as below, except we also require the course to have a finished date. - const hasPassedPreviousSemester = previousSemester.courses.every((course) => - studentCourses.some( - (studentCourse) => course === studentCourse.code && studentCourse.finished !== undefined - ) - ) + const { semester, courses, minimumEnrolledCourses } = courseSet[i] + const hasMandatoryCourses = courses.length > 0 - if (!hasPassedPreviousSemester) { - continue - } + // If this semester has mandatory courses, we base our estimation on how many of the courses the user is taking. + // We don't consider if the student has passed or failed the courses or not. + if (hasMandatoryCourses) { + const mandatoryCoursesEnrolledIn = courses.filter((course) => + studentCourses.some((studentCourse) => course === studentCourse.code) + ) + + // We check that the user is enrolled in at least the minimum courses needed for this semester. + if (mandatoryCoursesEnrolledIn.length >= minimumEnrolledCourses) { + // Given they are, we keep the largest semester we have found so far, and continue with the further semesters. + largestSemester = Math.max(largestSemester, semester) + isPreviousSemesterValueEstimated = false + continue + } else { + // If they aren't attending in enough of the required courses for this semester, we would basically end our + // search here, as this is how far they have gotten into their studies. But users who have been exchange + // students won't have their non-NTNU courses recognized by us, so we continue the loop in case they have a + // "hole" in their course plan. + // We set that the previous semester value was estimated, even though we don't update our estimate. This is to + // prevent us from trying to estimate two semesters in a row. + // TLDR: We don't break here to attempt to better predict exchange students. + isPreviousSemesterValueEstimated = true + continue + } + } + + // Since there are no mandatory courses for this course set, we need to estimate from the previous course sets. + // To be able to determine this, there cannot be more than a single semester in a row without mandatory courses. + // We make an exception if, and only if, there are no previous semesters with mandatory courses. Then we would + // prefer to assign Autumn or Spring based off the current season and place them in the first year. This works + // great with the Master study plan (as of 2026) that begin with two semester without mandatory courses. + + const earlierCourseSetsWithMandatoryCourses = courseSet + .slice(0, i) + .filter((semester) => semester.courses.length !== 0) + + // We sum the number of mandatory courses in all the earlier course sets. + const earlierMandatoryCoursesCount = earlierCourseSetsWithMandatoryCourses.reduce( + (acc, semester) => acc + semester.courses.length, + 0 + ) - // There is a scenario where a user failed a course in year 1, but passed in year 2. This is why we take the - // mean distance, which is later ceiled. - const previousSemesterDistances = previousSemester.courses.map((course) => { - const studentCourse = studentCourses.find((studentCourse) => studentCourse.code === course) - - // -1 because length is 1-indexed - const semesterDistanceFromEnd = courseSet.length - 1 - previousSemester.semester - // If you were supposed to finish your degree this year, how far away would the semester in question be? - // For example; if previousSemester=3 (algdat+itp+datdig), then the distance would be 2. - const years = Math.ceil(semesterDistanceFromEnd / 2) - const courseEndAssumingLastYearStudent = subYears(getNextAcademicStart(), years) - - // INVARIANT: The course should exist, and it should be finished according to `hasPassedPreviousSemester`. - invariant(studentCourse !== undefined && studentCourse.finished !== undefined) - // We divide by six because we have two school semesters in a year, effectively turnings months in a year - // into semesters - const distance = Math.floor( - differenceInMonths(getAcademicStart(studentCourse.finished), courseEndAssumingLastYearStudent) / 6 - ) - - return previousSemester.semester + (semesterDistanceFromEnd - distance) - }) - - // Take the mean distance for this semester - const sum = previousSemesterDistances.reduce((acc, curr) => acc + curr, 0) - const currentSemesterEstimate = Math.ceil(sum / previousSemesterDistances.length) - - largestLocalSemester = Math.max(largestLocalSemester, currentSemesterEstimate) + // Here we assign you Spring or Autumn in the first year relative to the offset given. This is regardless of the + // number of semesters iterated. If you have 100 semesters without mandatory courses at the start of a study plan, + // they will still be placed in the first year relative to the offset regardless of the number of iterations. The + // reasoning for this is explained more in the comment above. + if (earlierMandatoryCoursesCount === 0) { + const isAutumnStart = semesterOffset % 2 === 0 + // If the offset is odd, we flip what is the "first" semester. In practice this will never happen, at least with + // the current 2026 study plan. + const isSecondSemester = isAutumnStart ? isSpringSemester() : isAutumnSemester() + + // It's important we assign the initial value (semesterOffset) here, else we would continually increment the + // value. + if (isSecondSemester) { + largestSemester = Math.max(largestSemester, semesterOffset + 1) + } else { + largestSemester = Math.max(largestSemester, semesterOffset) } - largestSemester = Math.max(largestSemester, largestLocalSemester) + // We allow the loop to continue so long there are no previous mandatory courses. + // NOTE: It does not matter if we set that the previous semester value was estimated here. continue } - // If the user has all the courses that are mandatory - const isGroupMemberOfMandatoryCourses = semester.courses.every((course) => - studentCourses.some((studentCourse) => course === studentCourse.code) - ) + // If the previous semester was estimated, we cannot estimate another consecutive semester. + if (isPreviousSemesterValueEstimated) { + continue + } + + invariant(earlierCourseSetsWithMandatoryCourses.length !== 0) + + // This is the maximum estimated value described in the comment below. + let largestLocalSemester = semesterOffset + + // This calls toReversed to look at the most recent course sets first for estimating distance. This isn't strictly + // necessary, but it makes more intuitive sense. + // + // NOTE: In this loop we care about if the user has PASSED a course and not just if the user is ENROLLED, like in + // an earlier check. This is because we are looking at past semesters, and need the courses to be finished to + // determine how long ago the semester was. + // + // The idea here is to take an estimate of how long ago (in number of semesters) each previously iterated semester + // is compared to today's date, then take the largest of all the estimates. + // + // EXAMPLE: + // If you imagine today is Autumn 2026, and it is a user's third semester, we would loop through all previously + // iterated semesters. We start with Spring 2026 (semester value = 1, because it is the second semester), and find + // that looking at the dates of the courses, it was one semester ago. The loop would end with the value 1 + 1 = 2, + // where the first one is the semester value of the semester, and the second one is the value we estimated. We + // save the value, then go to the previous semester. Autumn 2025 (semester value = 0, because it is the first + // semester), and we find that the distance is 2 semesters. The value is therefore 0 + 2 = 2. The maximum of all + // the estimations is the value of 2 (which corresponds to the third semesters), and this is what we assign. + for (const earlierCourseSet of earlierCourseSetsWithMandatoryCourses.toReversed()) { + const coursesPassedEarlierSemester = earlierCourseSet.courses.filter((course) => + studentCourses.some((studentCourse) => course === studentCourse.code && studentCourse.finished !== undefined) + ) - if (isGroupMemberOfMandatoryCourses) { - largestSemester = semester.semester - } else { - // If the user does not have all the courses required for this semester, we would much rather prefer to give - // them a lower year than a higher one. Chances are a student would notify HS (basically our administration) - // if they cannot attend events they should be able to, while someone might not notify HS about them being able - // to attend company events they are not supposed to be at. - break + // If the user didn't pass enough courses in the semester, we skip as we cannot use this see + if (coursesPassedEarlierSemester.length < earlierCourseSet.minimumEnrolledCourses) { + continue + } + + const currentSemesterStart = isSpringSemester() ? getSpringSemesterStart() : getAutumnSemesterStart() + + // We collect how long ago (distance) each term in the earlier semesters were finished + const earlierSemesterDistanceEstimates = earlierCourseSet.courses.map((course) => { + const studentCourse = studentCourses.find( + (studentCourse) => studentCourse.code === course && studentCourse.finished !== undefined + ) + + if (studentCourse?.finished === undefined) { + return null + } + + const semesterStartForFinishedCourse = isSpringSemester(studentCourse.finished) + ? getSpringSemesterStart(studentCourse.finished) + : getAutumnSemesterStart(studentCourse.finished) + + const semestersPassed = getSemesterDifference(semesterStartForFinishedCourse, currentSemesterStart) + + return earlierCourseSet.semester + semestersPassed + }) + + // We filter out the non-estimates + const estimates = earlierSemesterDistanceEstimates.filter((estimate): estimate is number => estimate !== null) + + if (estimates.length === 0) { + continue + } + + // We take the largest estimate from the earlier semesters, to not skew if they failed a course. + const maxEstimate = Math.max(...estimates) + // We clamp the estimate to the highest possible value (which is the semesters value) to avoid completely wrong + // estimates. + const clampedEstimate = Math.min(maxEstimate, semester) + + largestLocalSemester = Math.max(largestLocalSemester, clampedEstimate) } + + largestSemester = Math.max(largestSemester, largestLocalSemester) } - // Give the value back in years (two school semesters in a year). - // We use Math#round because it will give us the correct year delta: - // Year 1 fall (value 0) : round(0 / 2) = 0 (Start year = current year) - // Year 1 spring (value 1): round(1 / 2) = 1 (Start year = current - 1, since spring is in the next calendar year) - // Year 2 fall (value 2) : round(2 / 2) = 1 (Start year = current - 1) - // Year 2 spring (value 3): round(3 / 2) = 2 (Start year = current - 2) - return Math.round(largestSemester / 2) + logger.info("Finished searching for applicable membership. Estimated semester was %d", largestSemester) + + return largestSemester } + return { - findMasterStartYearDelta(courses) { - return findApproximateStartYear(courses, MASTER_STUDY_PLAN) - }, + findEstimatedSemester(study, courses) { + const studyPlan = study === "MASTER" ? MASTER_STUDY_PLAN : BACHELOR_STUDY_PLAN + const offset = study === "MASTER" ? MASTER_SEMESTER_OFFSET : 0 - findBachelorStartYearDelta(courses) { - return findApproximateStartYear(courses, BACHELOR_STUDY_PLAN) + return estimateSemester(studyPlan, courses, offset) }, } } diff --git a/apps/rpc/src/modules/user/user-service.ts b/apps/rpc/src/modules/user/user-service.ts index e95df11e29..6ee1e58514 100644 --- a/apps/rpc/src/modules/user/user-service.ts +++ b/apps/rpc/src/modules/user/user-service.ts @@ -3,10 +3,10 @@ import type { PresignedPost } from "@aws-sdk/s3-presigned-post" import type { DBHandle } from "@dotkomonline/db" import { getLogger } from "@dotkomonline/logger" import { + isMembershipActive, type Membership, type MembershipId, type MembershipSpecialization, - MembershipSpecializationSchema, type MembershipWrite, USER_IMAGE_MAX_SIZE_KIB, type User, @@ -16,19 +16,23 @@ import { type UserWrite, UserWriteSchema, findActiveMembership, - getAcademicStart, - getNextAcademicStart, } from "@dotkomonline/types" -import { createS3PresignedPost, getCurrentUTC, slugify } from "@dotkomonline/utils" +import { createS3PresignedPost, slugify, getNextSemesterStart, getCurrentSemesterStart } from "@dotkomonline/utils" import { trace } from "@opentelemetry/api" import type { ManagementClient } from "auth0" -import { isSameDay, subYears } from "date-fns" import * as crypto from "node:crypto" import { isDevelopmentEnvironment } from "../../configuration" +import { isSameDay } from "date-fns" import { AlreadyExistsError, IllegalStateError, InvalidArgumentError, NotFoundError } from "../../error" import type { Pageable } from "../../query" import type { FeideGroupsRepository, NTNUGroup } from "../feide/feide-groups-repository" -import type { MembershipService } from "./membership-service" +import { + BACHELOR_FIRST_SEMESTER, + BACHELOR_LAST_SEMESTER, + MASTER_FIRST_SEMESTER, + MASTER_LAST_SEMESTER, + type MembershipService, +} from "./membership-service" import type { UserRepository } from "./user-repository" export interface UserService { @@ -121,22 +125,36 @@ export function getUserService( return null } - // Master degree always takes precedence over bachelor every single time. - const isMasterStudent = masterProgramme !== undefined - const distanceFromStartInYears = isMasterStudent - ? membershipService.findMasterStartYearDelta(courses) - : membershipService.findBachelorStartYearDelta(courses) - const estimatedStudyStart = subYears(getAcademicStart(getCurrentUTC()), distanceFromStartInYears) - - // NOTE: We grant memberships for at most one year at a time. If you are granted membership after new-years, you - // will only keep the membership until the start of the next school year. - const endDate = getNextAcademicStart() - - // Master's programme takes precedence over bachelor's programme. - if (masterProgramme !== undefined) { - const code = MembershipSpecializationSchema.catch("UNKNOWN").parse( - getSpecializationFromCode(studySpecializations?.[0].code) - ) + // Master degree always takes precedence over bachelor. + const study = masterProgramme !== undefined ? "MASTER" : "BACHELOR" + const estimatedSemester = membershipService.findEstimatedSemester(study, courses) + + // We grant memberships for one semester at a time. This has some trade-offs, and natural alternative end dates are: + // 1. One semester (what we use) + // 2. School year (one or two semesters--until next Autumn semester, earlier referred to as the next + // "academic start") + // 3. Entire degree (three years for Bachelor's and two years for Master's) + // + // The longer each membership lasts, the fewer times you need to calculate the grade and other information. This + // reduces the number of opportunities for wrong calculations, but also make the system less flexible. Sometimes + // students take a Bachelor's degree over a span of two years. Other times they change study. We choose the tradeoff + // where you have this flexibility, even though it costs us an increase in manual adjustments. You most often need + // to manually adjust someone's membership if someone: + // a) Failed at least one of their courses a semester. + // b) Has a very weird study plan due to previous studies. + // c) Have been an exchange student and therefore not have done all their courses in the "correct" order + // (according to our system anyway), where they have a "hole" in their course list exposed to us. + // + // We have decided it is best to manually adjust the membership in any nonlinear case, versus trying to correct for + // fairly common cases like exchange students automatically. We never want this heuristic to overestimate someone's + // grade. This is because we deem it generally less beneficial to be in a lower grade (because in practice the older + // students usually have priority for attendance), increasing their chances of reaching out to correct the error. + const startDate = getCurrentSemesterStart() + const endDate = getNextSemesterStart() + + if (study === "MASTER") { + const code = getSpecializationFromCode(studySpecializations?.[0].code) + // If we have a new code that we have not seen, or for some other reason the code catches and returns UNKNOWN, we // emit a trace for it. if (code === "UNKNOWN") { @@ -150,18 +168,20 @@ export function getUserService( logger.info("Estimated end date for the master's programme to be %s", endDate.toUTCString()) return { type: "MASTER_STUDENT", - start: estimatedStudyStart, + start: startDate, end: endDate, specialization: code, + semester: estimatedSemester, } } logger.info("Estimated end date for the bachelor's programme to be %s", endDate.toUTCString()) return { type: "BACHELOR_STUDENT", - start: estimatedStudyStart, + start: startDate, end: endDate, specialization: null, + semester: estimatedSemester, } } @@ -239,7 +259,9 @@ export function getUserService( if (existingUser !== null) { const membership = findActiveMembership(existingUser) - if (membership !== null) { + // If the best active membership is KNIGHT, we attempt to discover a new membership for the user, in case they + // can find a "better" membership this way. + if (membership !== null && membership.type !== "KNIGHT") { return existingUser } @@ -382,11 +404,21 @@ export function getUserService( // We can only replace memberships if there is a new applicable one for the user if ( - shouldReplaceMembership(user.memberships, activeMembership, applicableMembership) && - applicableMembership !== null + applicableMembership !== null && + shouldReplaceMembership(user.memberships, activeMembership, applicableMembership) ) { - logger.info("Discovered applicable membership for user %s: %o", user.id, applicableMembership) - await userRepository.createMembership(handle, user.id, applicableMembership) + // We make sure the membership is active before creating it. If it is not active, something has gone + // wrong in our logic. + if (isMembershipActive(applicableMembership)) { + logger.info("Discovered applicable membership for user %s: %o", user.id, applicableMembership) + await userRepository.createMembership(handle, user.id, applicableMembership) + } else { + logger.warn( + "Discovered and discarded invalid membership for user %s: %o", + user.id, + applicableMembership + ) + } } } } finally { @@ -399,12 +431,50 @@ export function getUserService( }, async createMembership(handle, userId, data) { + switch (data.type) { + case "BACHELOR_STUDENT": { + validateBachelorMembership(data) + break + } + case "MASTER_STUDENT": { + validateMasterMembership(data) + break + } + case "SOCIAL_MEMBER": { + validateSocialMembership(data) + break + } + case "KNIGHT": { + validateKnightMembership(data) + break + } + } + const user = await this.getById(handle, userId) return await userRepository.createMembership(handle, user.id, data) }, async updateMembership(handle, membershipId, membership) { + switch (membership.type) { + case "BACHELOR_STUDENT": { + validateBachelorMembership(membership) + break + } + case "MASTER_STUDENT": { + validateMasterMembership(membership) + break + } + case "SOCIAL_MEMBER": { + validateSocialMembership(membership) + break + } + case "KNIGHT": { + validateKnightMembership(membership) + break + } + } + return userRepository.updateMembership(handle, membershipId, membership) }, @@ -440,6 +510,15 @@ export function getUserService( } } +/** + * Determine if we should replace a previous membership with a new one. + * + * This is true if: + * - There is no previous membership. + * - The membership is not a duplicate of an existing membership (active or inactive). + * - The previous membership is not active, and the next one is active. + * - The next membership has a semester greater than or equal to the previous one. + */ function shouldReplaceMembership( allMemberships: Membership[], previous: Membership | null, @@ -458,16 +537,20 @@ function shouldReplaceMembership( return true } - if (previous.type === "BACHELOR_STUDENT" && next.type === "MASTER_STUDENT") { + if (!isMembershipActive(previous) && isMembershipActive(next)) { return true } - return previous.type === "SOCIAL_MEMBER" + // Returns true if the next semester is greater than or equal to the previous semester + return (next.semester ?? -Infinity) - (previous.semester ?? -Infinity) >= 0 } function areMembershipsEqual(a: Membership, b: MembershipWrite) { + const isSameStart = isSameDay(a.start, b.start) + const isSameEnd = (a.end === null && b.end === null) || (a.end !== null && b.end !== null && isSameDay(a.end, b.end)) + return ( - isSameDay(a.start, b.start) && isSameDay(a.end, b.end) && a.specialization === b.specialization && a.type === b.type + isSameStart && isSameEnd && a.specialization === b.specialization && a.type === b.type && a.semester === b.semester ) } @@ -485,3 +568,91 @@ function getSpecializationFromCode(code: string): MembershipSpecialization { } return "UNKNOWN" } + +function validateBachelorMembership(membership: Partial) { + if (membership.specialization !== undefined && membership.specialization !== null) { + throw new InvalidArgumentError("Bachelor memberships cannot have a Master specialization") + } + + if (membership.semester !== undefined) { + const isValidBachelorSemester = + membership.semester !== null && + membership.semester >= BACHELOR_FIRST_SEMESTER && + membership.semester <= BACHELOR_LAST_SEMESTER + + if (!isValidBachelorSemester) { + throw new InvalidArgumentError( + `Bachelor memberships must have a semester value between ${BACHELOR_FIRST_SEMESTER} and ${BACHELOR_LAST_SEMESTER}` + ) + } + } + + if (membership.end === null) { + throw new InvalidArgumentError("Bachelor memberships must have a value for end") + } +} + +function validateMasterMembership(membership: Partial) { + if (membership.specialization === null) { + throw new InvalidArgumentError("Master memberships must have a Master specialization") + } + + if (membership.semester !== undefined) { + const isValidMasterSemester = + membership.semester !== null && + membership.semester >= MASTER_FIRST_SEMESTER && + membership.semester <= MASTER_LAST_SEMESTER + + if (!isValidMasterSemester) { + throw new InvalidArgumentError( + `Master memberships must have a semester value between ${MASTER_FIRST_SEMESTER} and ${MASTER_LAST_SEMESTER}` + ) + } + } + + if (membership.end === null) { + throw new InvalidArgumentError("Master memberships must have a value for end") + } +} + +function validateSocialMembership(membership: Partial) { + if (membership.specialization !== undefined && membership.specialization !== null) { + throw new InvalidArgumentError("Social memberships cannot have a Master specialization") + } + + if (membership.semester !== undefined) { + const isValidBachelorSemester = + membership.semester !== null && + membership.semester >= BACHELOR_FIRST_SEMESTER && + membership.semester <= BACHELOR_LAST_SEMESTER + + const isValidMasterSemester = + membership.semester !== null && + membership.semester >= MASTER_FIRST_SEMESTER && + membership.semester <= MASTER_LAST_SEMESTER + + if (!isValidBachelorSemester && !isValidMasterSemester) { + throw new InvalidArgumentError( + `Social memberships must have a semester value between (${BACHELOR_FIRST_SEMESTER} and ${BACHELOR_LAST_SEMESTER}) OR (${MASTER_FIRST_SEMESTER} and ${MASTER_LAST_SEMESTER})` + ) + } + } + + if (membership.end === null) { + throw new InvalidArgumentError("Social memberships must have a value for end") + } +} + +function validateKnightMembership(membership: Partial) { + if (membership.specialization !== undefined && membership.specialization !== null) { + throw new InvalidArgumentError("Knight memberships cannot have a Master specialization") + } + + if (membership.semester !== undefined && membership.semester !== null) { + throw new InvalidArgumentError("Knight memberships cannot have a semester value") + } + + if (membership.end !== undefined && membership.end !== null) { + throw new InvalidArgumentError("Knight memberships are lifetime memberships and cannot have a value for end") + } +} diff --git a/apps/rpc/src/scripts/migrate-users-from-ow4.ts b/apps/rpc/src/scripts/migrate-users-from-ow4.ts index 23dd4625c8..f5d865a285 100644 --- a/apps/rpc/src/scripts/migrate-users-from-ow4.ts +++ b/apps/rpc/src/scripts/migrate-users-from-ow4.ts @@ -67,10 +67,10 @@ const rawMemberships = JSON.parse( ) const users = OW4UserSchema.array().parse(rawUsers) -const memberships = OW4MembershipSchema.array().parse(rawMemberships) +const ow4Memberships = OW4MembershipSchema.array().parse(rawMemberships) for (const user of users) { - const membership = memberships.find((m) => m.username === user.ntnu_username) + const ow4Membership = ow4Memberships.find((m) => m.username === user.ntnu_username) if (user.auth0_subject === null) { console.error(`User with OW4 id ${user.id} is missing auth0 subject`) @@ -99,6 +99,8 @@ for (const user of users) { const name = user.first_name || user.last_name ? `${user.first_name} ${user.last_name}`.trim() : auth0User.name + const ow5MembershipType = getMembershipType(user, ow4Membership) + await prisma.user.create({ data: { id: user.auth0_subject, @@ -114,16 +116,17 @@ for (const user of users) { imageUrl: auth0User.imageUrl, memberships: { createMany: { - data: membership - ? [ - { - end: membership.expiration_date, - start: membership.registered, - type: getMembershipType(user, membership), - specialization: getMembershipSpecialization(user.field_of_study), - }, - ] - : [], + data: + ow4Membership && ow5MembershipType + ? [ + { + end: ow4Membership.expiration_date, + start: ow4Membership.registered, + type: ow5MembershipType, + specialization: getMembershipSpecialization(user.field_of_study), + }, + ] + : [], }, }, }, @@ -170,7 +173,11 @@ async function getAuth0User( // https://github.com/dotkom/onlineweb4/blob/main/apps/authentication/constants.py#L21 -function getMembershipType(user: OW4User, membership: OW4Membership): Membership["type"] { +function getMembershipType(user: OW4User, membership?: OW4Membership): Membership["type"] | null { + if (!membership) { + return null + } + // Memberships with manual expiration date >= 2100 are knights if (membership.expiration_date >= new TZDate(2100, 1, 1)) return "KNIGHT" @@ -183,10 +190,8 @@ function getMembershipType(user: OW4User, membership: OW4Membership): Membership return "BACHELOR_STUDENT" case 40: return "SOCIAL_MEMBER" - case 80: - return "PHD_STUDENT" default: - return "OTHER" + return null } } diff --git a/apps/web/src/app/profil/[profileSlug]/ProfilePage.tsx b/apps/web/src/app/profil/[profileSlug]/ProfilePage.tsx index 69d6b9ac8e..618300f8a5 100644 --- a/apps/web/src/app/profil/[profileSlug]/ProfilePage.tsx +++ b/apps/web/src/app/profil/[profileSlug]/ProfilePage.tsx @@ -13,7 +13,6 @@ import { type VisiblePersonalMarkDetails, createGroupPageUrl, findActiveMembership, - getMembershipGrade, getMembershipTypeName, getSpecializationName, } from "@dotkomonline/types" @@ -32,7 +31,14 @@ import { TooltipTrigger, cn, } from "@dotkomonline/ui" -import { capitalizeFirstLetter, createAuthorizeUrl, getCurrentUTC, getPunishmentExpiryDate } from "@dotkomonline/utils" +import { + capitalizeFirstLetter, + createAuthorizeUrl, + getCurrentUTC, + getPunishmentExpiryDate, + getStudyGrade, + isMembershipActiveUntilNextSemesterStart, +} from "@dotkomonline/utils" import { IconAlertTriangle, IconChefHatOff, @@ -141,35 +147,41 @@ function MarkDisplay({ markInformation: { mark, personalMark } }: { markInformat ) } -const MembershipDisplay = ({ - activeMembership, - grade, -}: { - activeMembership: Membership | null - grade: number | null -}) => { - if (activeMembership) { +const MembershipDisplay = ({ activeMembership }: { activeMembership: Membership | null }) => { + if (!activeMembership) { return ( - <> - -
- {getMembershipTypeName(activeMembership.type)} - {activeMembership.specialization && {getSpecializationName(activeMembership.specialization)}} - {grade}. klasse - - Medlemskapet varer fra {formatDate(activeMembership.start, "MMM yyyy")} til{" "} - {formatDate(activeMembership.end, "MMM yyyy")} - -
- +
+ + Ingen medlemskap +
) } + const grade = activeMembership.semester !== null ? getStudyGrade(activeMembership.semester) : null + const membershipActiveUntilNextSemesterStart = + activeMembership.end !== null ? isMembershipActiveUntilNextSemesterStart(activeMembership.end) : null + const isMembershipIndefinite = activeMembership.end === null + + let membershipValidUntilText = null + + if (isMembershipIndefinite) { + membershipValidUntilText = "Livstidsmedlemskap" + } else if (membershipActiveUntilNextSemesterStart) { + membershipValidUntilText = "Gyldig til starten av neste semester" + } else if (activeMembership.end) { + membershipValidUntilText = `Gyldig til ${formatDate(activeMembership.end, "dd. MMMM yyyy", { locale: nb })}` + } + return ( - <> - - Ingen medlemskap - +
+ +
+ {getMembershipTypeName(activeMembership.type)} + {activeMembership.specialization && {getSpecializationName(activeMembership.specialization)}} + {grade !== null ? {grade}. klasse : null} + {membershipValidUntilText} +
+
) } @@ -251,7 +263,7 @@ export function ProfilePage() { } const activeMembership = findActiveMembership(user) - const grade = activeMembership ? getMembershipGrade(activeMembership) : null + const grade = activeMembership?.semester != null ? getStudyGrade(activeMembership.semester) : null const dashboardUrl = new URL(`/brukere/${user.id}`, env.NEXT_PUBLIC_DASHBOARD_URL).toString() @@ -378,9 +390,7 @@ export function ProfilePage() { )} -
- -
+ {!activeMembership ? ( <> @@ -411,6 +421,8 @@ export function ProfilePage() { ) : ( + Medlemskap er gyldig på semesterbasis. +
Ved feil angitt informasjon, ta kontakt med{" "} Hovedstyret diff --git a/packages/db/prisma/migrations/20260218135233_add_semester_to_membership/migration.sql b/packages/db/prisma/migrations/20260218135233_add_semester_to_membership/migration.sql new file mode 100644 index 0000000000..b142307a95 --- /dev/null +++ b/packages/db/prisma/migrations/20260218135233_add_semester_to_membership/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - The values [OTHER] on the enum `membership_type` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "membership_type_new" AS ENUM ('BACHELOR_STUDENT', 'MASTER_STUDENT', 'PHD_STUDENT', 'KNIGHT', 'SOCIAL_MEMBER'); +ALTER TABLE "membership" ALTER COLUMN "type" TYPE "membership_type_new" USING ("type"::text::"membership_type_new"); +ALTER TYPE "membership_type" RENAME TO "membership_type_old"; +ALTER TYPE "membership_type_new" RENAME TO "membership_type"; +DROP TYPE "public"."membership_type_old"; +COMMIT; + +-- AlterTable +ALTER TABLE "membership" ADD COLUMN "semester" INTEGER, +ALTER COLUMN "end" DROP NOT NULL; diff --git a/packages/db/prisma/migrations/20260219122315_remove_phd_student_membership_type/migration.sql b/packages/db/prisma/migrations/20260219122315_remove_phd_student_membership_type/migration.sql new file mode 100644 index 0000000000..de245bede6 --- /dev/null +++ b/packages/db/prisma/migrations/20260219122315_remove_phd_student_membership_type/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [PHD_STUDENT] on the enum `membership_type` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "membership_type_new" AS ENUM ('BACHELOR_STUDENT', 'MASTER_STUDENT', 'KNIGHT', 'SOCIAL_MEMBER'); +ALTER TABLE "membership" ALTER COLUMN "type" TYPE "membership_type_new" USING ("type"::text::"membership_type_new"); +ALTER TYPE "membership_type" RENAME TO "membership_type_old"; +ALTER TYPE "membership_type_new" RENAME TO "membership_type"; +DROP TYPE "public"."membership_type_old"; +COMMIT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index fafd67b633..1c56293a96 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -21,10 +21,17 @@ datasource db { enum MembershipType { BACHELOR_STUDENT @map("BACHELOR_STUDENT") MASTER_STUDENT @map("MASTER_STUDENT") - PHD_STUDENT @map("PHD_STUDENT") + /// "Ridder av det Indre Lager" is an honorary membership type for those who have exceptionally contributed beyond + /// expectations to Linjeforeningen Online. For our system, it is functionally identical to `SOCIAL_MEMBER` (sosialt + /// medlem), but it is highly prestigious and grants a lifetime membership. KNIGHT @map("KNIGHT") + /// "Sosialt medlem" is a membership type for those who want membership with Linjeforeningen Online, but are not + /// technically informatics students at NTNU (commonly those who internally transfer from other study programs at + /// NTNU). + /// + /// This membership type exists to not allow them to attend (most) events with companies, as we usually sign + /// contracts saying we will provide "informatics students", but we still want to include them for all other events. SOCIAL_MEMBER @map("SOCIAL_MEMBER") - OTHER @map("OTHER") @@map("membership_type") } @@ -45,12 +52,22 @@ enum MembershipSpecialization { model Membership { id String @id @default(uuid()) - userId String @map("user_id") - user User @relation(fields: [userId], references: [id]) type MembershipType + /// Specialization of the student. This is relevant for Master students. For all other students, this value should be + /// `UNKNOWN`. specialization MembershipSpecialization? @default(UNKNOWN) start DateTime @db.Timestamptz(3) - end DateTime @db.Timestamptz(3) + /// End date of the membership. Null means the membership does not have an end date, and lasts forever. + end DateTime? @db.Timestamptz(3) + /// The semester the student is in. 0-indexed. + /// + /// This value is meant to be used to calculate study grade (year), and can be used to calculate study start date + /// with the `start` date of the membership. It is nullable because `KNIGHT` membership type is a lifetime + /// membership. + semester Int? + + userId String @map("user_id") + user User @relation(fields: [userId], references: [id]) @@index([userId], name: "idx_membership_user_id") @@map("membership") diff --git a/packages/db/src/fixtures/membership.ts b/packages/db/src/fixtures/membership.ts index 192c4eaccc..366949d1f0 100644 --- a/packages/db/src/fixtures/membership.ts +++ b/packages/db/src/fixtures/membership.ts @@ -1,30 +1,31 @@ import type { Prisma } from "@prisma/client" -import { addYears, roundToNearestHours, subYears } from "date-fns" +import { getCurrentSemesterStart, getNextSemesterStart, isSpringSemester } from "@dotkomonline/utils" -const now = roundToNearestHours(new Date(), { roundingMethod: "ceil" }) - -const lastYear = subYears(now, 1) -const nextYear = addYears(now, 1) +const isSpring = isSpringSemester() +const start = getCurrentSemesterStart() +const end = getNextSemesterStart() export const getMembershipFixtures = (userIds: string[]) => [ { userId: userIds[0], - start: lastYear, + start, type: "BACHELOR_STUDENT", - end: nextYear, + end, + semester: isSpring ? 3 : 4, }, { userId: userIds[1], - start: lastYear, + start, type: "KNIGHT", - end: nextYear, + end, }, { userId: userIds[2], - start: subYears(now, 4), + start, type: "MASTER_STUDENT", - end: nextYear, + end, specialization: "SOFTWARE_ENGINEERING", + semester: isSpring ? 7 : 6, }, ] as const satisfies Prisma.MembershipCreateManyInput[] diff --git a/packages/types/package.json b/packages/types/package.json index 571ff757fb..4cc11ecd63 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -14,6 +14,7 @@ "@dotkomonline/db": "workspace:*", "@dotkomonline/utils": "workspace:*", "date-fns": "^4.1.0", + "tiny-invariant": "^1.3.3", "zod": "^3.25.47" }, "devDependencies": { diff --git a/packages/types/src/attendance.ts b/packages/types/src/attendance.ts index 0f8b244cea..b7a831ee0c 100644 --- a/packages/types/src/attendance.ts +++ b/packages/types/src/attendance.ts @@ -1,7 +1,8 @@ import { schemas } from "@dotkomonline/db/schemas" import { compareAsc } from "date-fns" import { z } from "zod" -import { type User, type UserId, UserSchema, findActiveMembership, getMembershipGrade } from "./user" +import { type User, type UserId, UserSchema, findActiveMembership } from "./user" +import { getStudyGrade } from "@dotkomonline/utils" // TODO: Where on earth does this come from? export type AttendanceStatus = "NotOpened" | "Open" | "Closed" @@ -129,11 +130,8 @@ export function getAttendanceCapacity(attendance: Attendance | AttendanceSummary export function isAttendable(user: User, pool: AttendancePool) { const membership = findActiveMembership(user) - if (membership === null) { - return false - } + const grade = membership?.semester != null ? getStudyGrade(membership.semester) : null - const grade = getMembershipGrade(membership) if (grade === null) { return false } diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 8815f3e402..8efdeb954a 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -1,7 +1,7 @@ import type { TZDate } from "@date-fns/tz" import { schemas } from "@dotkomonline/db/schemas" import { getCurrentUTC, slugify } from "@dotkomonline/utils" -import { addYears, isAfter, isBefore, setMonth, startOfMonth } from "date-fns" +import { isAfter, isBefore } from "date-fns" import { z } from "zod" import { buildSearchFilter } from "./filters" @@ -20,6 +20,7 @@ export const MembershipWriteSchema = MembershipSchema.pick({ start: true, end: true, specialization: true, + semester: true, }) export type MembershipWrite = z.infer @@ -86,40 +87,47 @@ export const UserFilterQuerySchema = z .partial() export type UserFilterQuery = z.infer -/** Get the most relevant active membership for a user. */ -export function findActiveMembership(user: User): Membership | null { - const now = getCurrentUTC() - return user.memberships.findLast((membership) => isAfter(membership.end, now)) ?? null -} +export function isMembershipActive( + membership: Membership | MembershipWrite, + now: TZDate | Date = getCurrentUTC() +): boolean { + if (isAfter(membership.start, now)) { + return false + } -export function getMembershipGrade(membership: Membership): 1 | 2 | 3 | 4 | 5 | null { - const now = getCurrentUTC() + if (membership.end && isBefore(membership.end, now)) { + return false + } - // Make sure we clamp the value to a minimum of 1 - const delta = Math.max(1, getAcademicYearDelta(membership.start, now)) + return true +} - switch (membership.type) { - case "KNIGHT": - case "PHD_STUDENT": - return 5 +/** + * Get the most relevant active membership for a user. Most relevant is defined as the membership with the highest + * semester. + * + * This will always deprioritize KNIGHT (Ridder) memberships in favor of student or social memberships, because they are + * easier to work with for our attendance systems. + */ +export function findActiveMembership(user: User): Membership | null { + const now = getCurrentUTC() - case "SOCIAL_MEMBER": - return 1 + // This orders active memberships by semester descending with null values last + const orderedMemberships = user.memberships + .filter((membership) => isMembershipActive(membership, now)) + .toSorted((a, b) => { + if (a.semester === null && b.semester === null) { + return 0 + } - case "BACHELOR_STUDENT": { - // Bachelor students are clamped at 1-3, regardless of how many years they used to take the degree. - return Math.min(3, delta) as 1 | 2 | 3 - } + if (a.semester !== null && b.semester !== null) { + return b.semester - a.semester + } - case "MASTER_STUDENT": { - // Master students are clamped at 4-5, and are always considered to have a bachelor's degree from beforehand. - const yearsWithBachelors = delta + 3 - return Math.min(5, yearsWithBachelors) as 4 | 5 - } + return b.semester !== null ? 1 : -1 + }) - case "OTHER": - return null - } + return orderedMemberships.at(0) ?? null } export function getMembershipTypeName(type: MembershipType) { @@ -132,10 +140,6 @@ export function getMembershipTypeName(type: MembershipType) { return "Sosialt medlem" case "KNIGHT": return "Ridder" - case "PHD_STUDENT": - return "PhD-student" - case "OTHER": - return "Annen" } } @@ -154,39 +158,4 @@ export function getSpecializationName(specialization: MembershipSpecialization) } } -/** Get the start of the academic year, which is by our convention August 1st. */ -export function getAcademicStart(date: TZDate | Date): TZDate { - // August is the 8th month, so we set the month to 7 (0-indexed) - return startOfMonth(setMonth(date, 7)) -} - -export function getNextAcademicStart(): TZDate { - const now = getCurrentUTC() - const firstAugust = getAcademicStart(getCurrentUTC()) - const isBeforeAugust = isBefore(now, firstAugust) - return isBeforeAugust ? firstAugust : addYears(firstAugust, 1) -} - -/** - * Calculates how many academic years have passed since the start date. - * If start is "last August" (current academic year), returns 1. - * If start was the August before that, returns 2. - */ -function getAcademicYearDelta(startDate: Date | TZDate, now: Date | TZDate = getCurrentUTC()): number { - const currentYear = now.getFullYear() - const currentMonth = now.getMonth() // 0-indexed (Jan=0, Aug=7) - - // If we are in Jan-July (0-6), the academic year started in the PREVIOUS calendar year - // If we are in Aug-Dec (7-11), the academic year started in THIS calendar year - const academicYearCurrent = currentMonth >= 7 ? currentYear : currentYear - 1 - - // We do the same normalization for the membership start date - // (Handling cases where a member might join in Jan/Feb) - const startYear = startDate.getFullYear() - const startMonth = startDate.getMonth() - const academicYearStart = startMonth >= 7 ? startYear : startYear - 1 - - return academicYearCurrent - academicYearStart + 1 -} - export const USER_IMAGE_MAX_SIZE_KIB = 512 diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index aef0b59dbd..76fba4b751 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -8,3 +8,4 @@ export * from "./create-default-pool-name" export * from "./s3" export * from "./rich-text-to-plain-text" export * from "./snake-case-to-camel-case" +export * from "./semester-helpers" diff --git a/packages/utils/src/semester-helpers.ts b/packages/utils/src/semester-helpers.ts new file mode 100644 index 0000000000..909ece293b --- /dev/null +++ b/packages/utils/src/semester-helpers.ts @@ -0,0 +1,179 @@ +import { TZDate } from "@date-fns/tz" +import { addYears, isBefore, subYears } from "date-fns" +import { getCurrentUTC } from "./utc" + +const JANUARY = 0 +const AUGUST = 7 + +/** + * Get the start of the academic year, which is by our convention January 1st. + * `January 1st -- -01-01T00:00:00.000Z` + */ +export const getSpringSemesterStart = (date: TZDate | Date = getCurrentUTC()) => { + return new TZDate(date.getFullYear(), JANUARY, 1, "UTC") +} + +/** + * Get the start of the academic year, which is by our convention August 1st. + * `August 1st -- -08-01T00:00:00.000Z` + */ +export const getAutumnSemesterStart = (date: TZDate | Date = getCurrentUTC()) => { + return new TZDate(date.getFullYear(), AUGUST, 1, "UTC") +} + +/** Is the given date or 0-indexed semester value representing a Spring semester? */ +export const isSpringSemester = (nowOrSemester: TZDate | Date | number = getCurrentUTC()): boolean => { + if (typeof nowOrSemester === "number") { + return nowOrSemester % 2 !== 0 + } + + return isBefore(nowOrSemester, getAutumnSemesterStart(nowOrSemester)) +} + +/** Is the given date or 0-indexed semester value representing an Autumn semester? */ +export const isAutumnSemester = (nowOrSemester: TZDate | Date | number = getCurrentUTC()): boolean => { + return !isSpringSemester(nowOrSemester) +} + +export function getNextAutumnSemesterStart(now: TZDate | Date = getCurrentUTC()): TZDate { + const autumnStart = getAutumnSemesterStart(now) + + if (isBefore(now, autumnStart)) { + return autumnStart + } + + return addYears(autumnStart, 1) +} + +export function getNextSpringSemesterStart(now: TZDate | Date = getCurrentUTC()): TZDate { + const springStart = getSpringSemesterStart(now) + + if (isBefore(now, springStart)) { + return springStart + } + + return addYears(springStart, 1) +} + +export function getPreviousAutumnSemesterStart(now: TZDate | Date = getCurrentUTC()): TZDate { + const autumnStart = getAutumnSemesterStart(now) + + if (isBefore(now, autumnStart)) { + return subYears(autumnStart, 1) + } + + return autumnStart +} + +export function getPreviousSpringSemesterStart(now: TZDate | Date = getCurrentUTC()): TZDate { + const springStart = getSpringSemesterStart(now) + + if (isBefore(now, springStart)) { + return subYears(springStart, 1) + } + + return springStart +} + +export function getCurrentSemesterStart(): TZDate { + const now = getCurrentUTC() + + if (isSpringSemester(now)) { + return getSpringSemesterStart(now) + } + + return getAutumnSemesterStart(now) +} + +export function getNextSemesterStart(now: TZDate | Date = getCurrentUTC()): TZDate { + if (isSpringSemester(now)) { + return getAutumnSemesterStart(now) + } + + return getSpringSemesterStart(addYears(now, 1)) +} + +export function getPreviousSemesterStart(now: TZDate | Date = getCurrentUTC()): TZDate { + if (isSpringSemester(now)) { + return getAutumnSemesterStart(addYears(now, -1)) + } + + return getSpringSemesterStart(now) +} + +export function isMembershipActiveUntilNextSemesterStart(membershipEnd: TZDate | Date): boolean { + return membershipEnd.getTime() === getNextSemesterStart().getTime() +} + +/** + * Subtract a number of semesters from the semester start for the input date. + * @return The start date of the resulting semester. + */ +export function subSemesters(date: TZDate | Date, semesters: number): TZDate { + const isSpring = isSpringSemester(date) + const yearsToSubtract = isSpring ? Math.floor(semesters / 2) : Math.ceil(semesters / 2) + const shouldSubtractSemester = semesters % 2 !== 0 + + const yearResult = subYears(new TZDate(date), yearsToSubtract) + + if (!shouldSubtractSemester) { + return yearResult + } + + if (isSpring) { + return getAutumnSemesterStart(subYears(yearResult, 1)) + } + + return getSpringSemesterStart(yearResult) +} + +/** + * Add a number of semesters to the semester start for the input date. + * @return The start date of the resulting semester. + */ +export function addSemesters(date: TZDate | Date, semesters: number): TZDate { + const isSpring = isSpringSemester(date) + const yearsToAdd = isSpring ? Math.ceil(semesters / 2) : Math.floor(semesters / 2) + const shouldAddSemester = semesters % 2 !== 0 + + const yearResult = addYears(new TZDate(date), yearsToAdd) + + if (!shouldAddSemester) { + return yearResult + } + + if (isSpring) { + return getAutumnSemesterStart(yearResult) + } + + return getSpringSemesterStart(addYears(yearResult, 1)) +} + +export function getSemesterDifference(start: TZDate | Date, end: TZDate | Date): number { + const startIsSpring = isSpringSemester(start) + const endIsSpring = isSpringSemester(end) + + const yearDifference = end.getFullYear() - start.getFullYear() + const semesterDifference = yearDifference * 2 + + if (startIsSpring && !endIsSpring) { + return semesterDifference + 1 + } + + if (!startIsSpring && endIsSpring) { + return semesterDifference - 1 + } + + return semesterDifference +} + +export function getStudyGrade(semester: number): number { + // A school year consists of two semesters (Autumn and Spring). So this formula will give us the year: + // Year 1 autumn (value 0): floor(0 / 2) + 1 = 1 (Year 1) + // Year 1 spring (value 1): floor(1 / 2) + 1 = 1 (Year 1) + // Year 2 autumn (value 2): floor(2 / 2) + 1 = 2 (Year 2) + // Year 2 spring (value 3): floor(3 / 2) + 1 = 2 (Year 2) + // Year 3 autumn (value 4): floor(4 / 2) + 1 = 3 (Year 3) + // ... + return Math.floor(semester / 2) + 1 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89981674cb..76fb8d875f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1077,6 +1077,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + tiny-invariant: + specifier: ^1.3.3 + version: 1.3.3 zod: specifier: ^3.25.47 version: 3.25.76