@@ -4,10 +4,10 @@ import type { PresignedPost } from "@aws-sdk/s3-presigned-post"
44import type { DBHandle } from "@dotkomonline/db"
55import { getLogger } from "@dotkomonline/logger"
66import {
7+ isMembershipActive ,
78 type Membership ,
89 type MembershipId ,
910 type MembershipSpecialization ,
10- MembershipSpecializationSchema ,
1111 type MembershipWrite ,
1212 type User ,
1313 type UserFilterQuery ,
@@ -16,14 +16,14 @@ import {
1616 type UserWrite ,
1717 UserWriteSchema ,
1818 findActiveMembership ,
19- getAcademicStart ,
20- getNextAcademicStart ,
19+ getNextSemesterStart ,
20+ getCurrentSemesterStart ,
2121} from "@dotkomonline/types"
22- import { createS3PresignedPost , getCurrentUTC , slugify } from "@dotkomonline/utils"
22+ import { createS3PresignedPost , slugify } from "@dotkomonline/utils"
2323import { trace } from "@opentelemetry/api"
2424import type { ManagementClient } from "auth0"
25- import { isSameDay , subYears } from "date-fns"
26- import { isDevelopmentEnvironment } from "../../configuration "
25+ import { isDevelopmentEnvironment } from "../../configuration" ;
26+ import { isSameDay } from "date-fns "
2727import { AlreadyExistsError , IllegalStateError , InvalidArgumentError , NotFoundError } from "../../error"
2828import type { Pageable } from "../../query"
2929import type { FeideGroupsRepository , NTNUGroup } from "../feide/feide-groups-repository"
@@ -120,22 +120,36 @@ export function getUserService(
120120 return null
121121 }
122122
123- // Master degree always takes precedence over bachelor every single time.
124- const isMasterStudent = masterProgramme !== undefined
125- const distanceFromStartInYears = isMasterStudent
126- ? membershipService . findMasterStartYearDelta ( courses )
127- : membershipService . findBachelorStartYearDelta ( courses )
128- const estimatedStudyStart = subYears ( getAcademicStart ( getCurrentUTC ( ) ) , distanceFromStartInYears )
129-
130- // NOTE: We grant memberships for at most one year at a time. If you are granted membership after new-years, you
131- // will only keep the membership until the start of the next school year.
132- const endDate = getNextAcademicStart ( )
133-
134- // Master's programme takes precedence over bachelor's programme.
135- if ( masterProgramme !== undefined ) {
136- const code = MembershipSpecializationSchema . catch ( "UNKNOWN" ) . parse (
137- getSpecializationFromCode ( studySpecializations ?. [ 0 ] . code )
138- )
123+ // Master degree always takes precedence over bachelor.
124+ const study = masterProgramme !== undefined ? "MASTER" : "BACHELOR"
125+ const estimatedStudyStart = membershipService . findEstimatedStudyStart ( study , courses )
126+
127+ // We grant memberships for one semester at a time. This has some trade-offs, and natural alternative end dates are:
128+ // 1. One semester (what we use)
129+ // 2. School year (one or two semesters--until next Autumn semester, earlier referred to as the next
130+ // "academic start")
131+ // 3. Entire degree (three years for Bachelor's and two years for Master's)
132+ //
133+ // The longer each membership lasts, the fewer times you need to calculate the grade and other information. This
134+ // reduces the number of opportunities for wrong calculations, but also make the system less flexible. Sometimes
135+ // students take a Bachelor's degree over a span of two years. Other times they change study. We choose the tradeoff
136+ // where you have this flexibility, even though it costs us an increase in manual adjustments. You most often need
137+ // to manually adjust someone's membership if someone:
138+ // a) Failed at least one of their courses a semester.
139+ // b) Has a very weird study plan due to previous studies.
140+ // c) Have been an exchange student and therefore not have done all their courses in the "correct" order
141+ // (according to our system anyway), where they have a "hole" in their course list exposed to us.
142+ //
143+ // We have decided it is best to manually adjust the membership in any nonlinear case, versus trying to correct for
144+ // fairly common cases like exchange students automatically. We never want this heuristic to overestimate someone's
145+ // grade. This is because we deem it generally less beneficial to be in a lower grade (because in practice the older
146+ // students usually have priority for attendance), increasing their chances of reaching out to correct the error.
147+ const endDate = getNextSemesterStart ( )
148+ const startDate = getCurrentSemesterStart ( )
149+
150+ if ( study === "MASTER" ) {
151+ const code = getSpecializationFromCode ( studySpecializations ?. [ 0 ] . code )
152+
139153 // If we have a new code that we have not seen, or for some other reason the code catches and returns UNKNOWN, we
140154 // emit a trace for it.
141155 if ( code === "UNKNOWN" ) {
@@ -149,18 +163,20 @@ export function getUserService(
149163 logger . info ( "Estimated end date for the master's programme to be %s" , endDate . toUTCString ( ) )
150164 return {
151165 type : "MASTER_STUDENT" ,
152- start : estimatedStudyStart ,
166+ start : startDate ,
153167 end : endDate ,
154168 specialization : code ,
169+ semester : estimatedStudyStart . semester ,
155170 }
156171 }
157172
158173 logger . info ( "Estimated end date for the bachelor's programme to be %s" , endDate . toUTCString ( ) )
159174 return {
160175 type : "BACHELOR_STUDENT" ,
161- start : estimatedStudyStart ,
176+ start : startDate ,
162177 end : endDate ,
163178 specialization : null ,
179+ semester : estimatedStudyStart . semester ,
164180 }
165181 }
166182
@@ -238,7 +254,9 @@ export function getUserService(
238254 if ( existingUser !== null ) {
239255 const membership = findActiveMembership ( existingUser )
240256
241- if ( membership !== null ) {
257+ // If the best active membership is KNIGHT, we attempt to discover a new membership for the user, in case they
258+ // can find a "better" membership this way.
259+ if ( membership !== null && membership . type !== "KNIGHT" ) {
242260 return existingUser
243261 }
244262
@@ -381,11 +399,21 @@ export function getUserService(
381399
382400 // We can only replace memberships if there is a new applicable one for the user
383401 if (
384- shouldReplaceMembership ( user . memberships , activeMembership , applicableMembership ) &&
385- applicableMembership !== null
402+ applicableMembership !== null &&
403+ shouldReplaceMembership ( user . memberships , activeMembership , applicableMembership )
386404 ) {
387- logger . info ( "Discovered applicable membership for user %s: %o" , user . id , applicableMembership )
388- await userRepository . createMembership ( handle , user . id , applicableMembership )
405+ // We make sure the membership is active before creating it. If it is not active, something has gone
406+ // wrong in our logic.
407+ if ( isMembershipActive ( applicableMembership ) ) {
408+ logger . info ( "Discovered applicable membership for user %s: %o" , user . id , applicableMembership )
409+ await userRepository . createMembership ( handle , user . id , applicableMembership )
410+ } else {
411+ logger . warn (
412+ "Discovered and discarded invalid membership for user %s: %o" ,
413+ user . id ,
414+ applicableMembership
415+ )
416+ }
389417 }
390418 }
391419 } finally {
@@ -442,6 +470,15 @@ export function getUserService(
442470 }
443471}
444472
473+ /**
474+ * Determine if we should replace a previous membership with a new one.
475+ *
476+ * This is true if:
477+ * - There is no previous membership.
478+ * - The membership is not a duplicate of an existing membership (active or inactive).
479+ * - The previous membership is not active, and the next one is active.
480+ * - The next membership has a semester greater than or equal to the previous one.
481+ */
445482function shouldReplaceMembership (
446483 allMemberships : Membership [ ] ,
447484 previous : Membership | null ,
@@ -460,16 +497,20 @@ function shouldReplaceMembership(
460497 return true
461498 }
462499
463- if ( previous . type === "BACHELOR_STUDENT" && next . type === "MASTER_STUDENT" ) {
500+ if ( ! isMembershipActive ( previous ) && isMembershipActive ( next ) ) {
464501 return true
465502 }
466503
467- return previous . type === "SOCIAL_MEMBER"
504+ // Returns true if the next semester is greater than or equal to the previous semester
505+ return ( next . semester ?? - Infinity ) - ( previous . semester ?? - Infinity ) >= 0
468506}
469507
470508function areMembershipsEqual ( a : Membership , b : MembershipWrite ) {
509+ const isSameStart = isSameDay ( a . start , b . start )
510+ const isSameEnd = ( a . end === null && b . end === null ) || ( a . end !== null && b . end !== null && isSameDay ( a . end , b . end ) )
511+
471512 return (
472- isSameDay ( a . start , b . start ) && isSameDay ( a . end , b . end ) && a . specialization === b . specialization && a . type === b . type
513+ isSameStart && isSameEnd && a . specialization === b . specialization && a . type === b . type && a . semester === b . semester
473514 )
474515}
475516
0 commit comments