@@ -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,13 +16,13 @@ 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"
25+ import { isSameDay } from "date-fns"
2626import { AlreadyExistsError , IllegalStateError , InvalidArgumentError , NotFoundError } from "../../error"
2727import type { Pageable } from "../../query"
2828import type { FeideGroupsRepository , NTNUGroup } from "../feide/feide-groups-repository"
@@ -119,22 +119,36 @@ export function getUserService(
119119 return null
120120 }
121121
122- // Master degree always takes precedence over bachelor every single time.
123- const isMasterStudent = masterProgramme !== undefined
124- const distanceFromStartInYears = isMasterStudent
125- ? membershipService . findMasterStartYearDelta ( courses )
126- : membershipService . findBachelorStartYearDelta ( courses )
127- const estimatedStudyStart = subYears ( getAcademicStart ( getCurrentUTC ( ) ) , distanceFromStartInYears )
128-
129- // NOTE: We grant memberships for at most one year at a time. If you are granted membership after new-years, you
130- // will only keep the membership until the start of the next school year.
131- const endDate = getNextAcademicStart ( )
132-
133- // Master's programme takes precedence over bachelor's programme.
134- if ( masterProgramme !== undefined ) {
135- const code = MembershipSpecializationSchema . catch ( "UNKNOWN" ) . parse (
136- getSpecializationFromCode ( studySpecializations ?. [ 0 ] . code )
137- )
122+ // Master degree always takes precedence over bachelor.
123+ const study = masterProgramme !== undefined ? "MASTER" : "BACHELOR"
124+ const estimatedStudyStart = membershipService . findEstimatedStudyStart ( study , courses )
125+
126+ // We grant memberships for one semester at a time. This has some trade-offs, and natural alternative end dates are:
127+ // 1. One semester (what we use)
128+ // 2. School year (one or two semesters--until next Autumn semester, earlier referred to as the next
129+ // "academic start")
130+ // 3. Entire degree (three years for Bachelor's and two years for Master's)
131+ //
132+ // The longer each membership lasts, the fewer times you need to calculate the grade and other information. This
133+ // reduces the number of opportunities for wrong calculations, but also make the system less flexible. Sometimes
134+ // students take a Bachelor's degree over a span of two years. Other times they change study. We choose the tradeoff
135+ // where you have this flexibility, even though it costs us an increase in manual adjustments. You most often need
136+ // to manually adjust someone's membership if someone:
137+ // a) Failed at least one of their courses a semester.
138+ // b) Has a very weird study plan due to previous studies.
139+ // c) Have been an exchange student and therefore not have done all their courses in the "correct" order
140+ // (according to our system anyway), where they have a "hole" in their course list exposed to us.
141+ //
142+ // We have decided it is best to manually adjust the membership in any nonlinear case, versus trying to correct for
143+ // fairly common cases like exchange students automatically. We never want this heuristic to overestimate someone's
144+ // grade. This is because we deem it generally less beneficial to be in a lower grade (because in practice the older
145+ // students usually have priority for attendance), increasing their chances of reaching out to correct the error.
146+ const endDate = getNextSemesterStart ( )
147+ const startDate = getCurrentSemesterStart ( )
148+
149+ if ( study === "MASTER" ) {
150+ const code = getSpecializationFromCode ( studySpecializations ?. [ 0 ] . code )
151+
138152 // If we have a new code that we have not seen, or for some other reason the code catches and returns UNKNOWN, we
139153 // emit a trace for it.
140154 if ( code === "UNKNOWN" ) {
@@ -148,18 +162,20 @@ export function getUserService(
148162 logger . info ( "Estimated end date for the master's programme to be %s" , endDate . toUTCString ( ) )
149163 return {
150164 type : "MASTER_STUDENT" ,
151- start : estimatedStudyStart ,
165+ start : startDate ,
152166 end : endDate ,
153167 specialization : code ,
168+ semester : estimatedStudyStart . semester ,
154169 }
155170 }
156171
157172 logger . info ( "Estimated end date for the bachelor's programme to be %s" , endDate . toUTCString ( ) )
158173 return {
159174 type : "BACHELOR_STUDENT" ,
160- start : estimatedStudyStart ,
175+ start : startDate ,
161176 end : endDate ,
162177 specialization : null ,
178+ semester : estimatedStudyStart . semester ,
163179 }
164180 }
165181
@@ -237,7 +253,9 @@ export function getUserService(
237253 if ( existingUser !== null ) {
238254 const membership = findActiveMembership ( existingUser )
239255
240- if ( membership !== null ) {
256+ // If the best active membership is KNIGHT, we attempt to discover a new membership for the user, in case they
257+ // can find a "better" membership this way.
258+ if ( membership !== null && membership . type !== "KNIGHT" ) {
241259 return existingUser
242260 }
243261
@@ -351,11 +369,21 @@ export function getUserService(
351369
352370 // We can only replace memberships if there is a new applicable one for the user
353371 if (
354- shouldReplaceMembership ( user . memberships , activeMembership , applicableMembership ) &&
355- applicableMembership !== null
372+ applicableMembership !== null &&
373+ shouldReplaceMembership ( user . memberships , activeMembership , applicableMembership )
356374 ) {
357- logger . info ( "Discovered applicable membership for user %s: %o" , user . id , applicableMembership )
358- await userRepository . createMembership ( handle , user . id , applicableMembership )
375+ // We make sure the membership is active before creating it. If it is not active, something has gone
376+ // wrong in our logic.
377+ if ( isMembershipActive ( applicableMembership ) ) {
378+ logger . info ( "Discovered applicable membership for user %s: %o" , user . id , applicableMembership )
379+ await userRepository . createMembership ( handle , user . id , applicableMembership )
380+ } else {
381+ logger . warn (
382+ "Discovered and discarded invalid membership for user %s: %o" ,
383+ user . id ,
384+ applicableMembership
385+ )
386+ }
359387 }
360388 }
361389 } finally {
@@ -412,6 +440,15 @@ export function getUserService(
412440 }
413441}
414442
443+ /**
444+ * Determine if we should replace a previous membership with a new one.
445+ *
446+ * This is true if:
447+ * - There is no previous membership.
448+ * - The membership is not a duplicate of an existing membership (active or inactive).
449+ * - The previous membership is not active, and the next one is active.
450+ * - The next membership has a semester greater than or equal to the previous one.
451+ */
415452function shouldReplaceMembership (
416453 allMemberships : Membership [ ] ,
417454 previous : Membership | null ,
@@ -430,16 +467,20 @@ function shouldReplaceMembership(
430467 return true
431468 }
432469
433- if ( previous . type === "BACHELOR_STUDENT" && next . type === "MASTER_STUDENT" ) {
470+ if ( ! isMembershipActive ( previous ) && isMembershipActive ( next ) ) {
434471 return true
435472 }
436473
437- return previous . type === "SOCIAL_MEMBER"
474+ // Returns true if the next semester is greater than or equal to the previous semester
475+ return ( next . semester ?? - Infinity ) - ( previous . semester ?? - Infinity ) >= 0
438476}
439477
440478function areMembershipsEqual ( a : Membership , b : MembershipWrite ) {
479+ const isSameStart = isSameDay ( a . start , b . start )
480+ const isSameEnd = ( a . end === null && b . end === null ) || ( a . end !== null && b . end !== null && isSameDay ( a . end , b . end ) )
481+
441482 return (
442- isSameDay ( a . start , b . start ) && isSameDay ( a . end , b . end ) && a . specialization === b . specialization && a . type === b . type
483+ isSameStart && isSameEnd && a . specialization === b . specialization && a . type === b . type && a . semester === b . semester
443484 )
444485}
445486
0 commit comments