@@ -3,10 +3,10 @@ import type { PresignedPost } from "@aws-sdk/s3-presigned-post"
33import type { DBHandle } from "@dotkomonline/db"
44import { getLogger } from "@dotkomonline/logger"
55import {
6+ isMembershipActive ,
67 type Membership ,
78 type MembershipId ,
89 type MembershipSpecialization ,
9- MembershipSpecializationSchema ,
1010 type MembershipWrite ,
1111 USER_IMAGE_MAX_SIZE_KIB ,
1212 type User ,
@@ -16,15 +16,15 @@ 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"
2625import * as crypto from "node:crypto"
2726import { isDevelopmentEnvironment } from "../../configuration"
27+ import { isSameDay } from "date-fns"
2828import { AlreadyExistsError , IllegalStateError , InvalidArgumentError , NotFoundError } from "../../error"
2929import type { Pageable } from "../../query"
3030import type { FeideGroupsRepository , NTNUGroup } from "../feide/feide-groups-repository"
@@ -121,22 +121,36 @@ export function getUserService(
121121 return null
122122 }
123123
124- // Master degree always takes precedence over bachelor every single time.
125- const isMasterStudent = masterProgramme !== undefined
126- const distanceFromStartInYears = isMasterStudent
127- ? membershipService . findMasterStartYearDelta ( courses )
128- : membershipService . findBachelorStartYearDelta ( courses )
129- const estimatedStudyStart = subYears ( getAcademicStart ( getCurrentUTC ( ) ) , distanceFromStartInYears )
130-
131- // NOTE: We grant memberships for at most one year at a time. If you are granted membership after new-years, you
132- // will only keep the membership until the start of the next school year.
133- const endDate = getNextAcademicStart ( )
134-
135- // Master's programme takes precedence over bachelor's programme.
136- if ( masterProgramme !== undefined ) {
137- const code = MembershipSpecializationSchema . catch ( "UNKNOWN" ) . parse (
138- getSpecializationFromCode ( studySpecializations ?. [ 0 ] . code )
139- )
124+ // Master degree always takes precedence over bachelor.
125+ const study = masterProgramme !== undefined ? "MASTER" : "BACHELOR"
126+ const estimatedStudyStart = membershipService . findEstimatedStudyStart ( study , courses )
127+
128+ // We grant memberships for one semester at a time. This has some trade-offs, and natural alternative end dates are:
129+ // 1. One semester (what we use)
130+ // 2. School year (one or two semesters--until next Autumn semester, earlier referred to as the next
131+ // "academic start")
132+ // 3. Entire degree (three years for Bachelor's and two years for Master's)
133+ //
134+ // The longer each membership lasts, the fewer times you need to calculate the grade and other information. This
135+ // reduces the number of opportunities for wrong calculations, but also make the system less flexible. Sometimes
136+ // students take a Bachelor's degree over a span of two years. Other times they change study. We choose the tradeoff
137+ // where you have this flexibility, even though it costs us an increase in manual adjustments. You most often need
138+ // to manually adjust someone's membership if someone:
139+ // a) Failed at least one of their courses a semester.
140+ // b) Has a very weird study plan due to previous studies.
141+ // c) Have been an exchange student and therefore not have done all their courses in the "correct" order
142+ // (according to our system anyway), where they have a "hole" in their course list exposed to us.
143+ //
144+ // We have decided it is best to manually adjust the membership in any nonlinear case, versus trying to correct for
145+ // fairly common cases like exchange students automatically. We never want this heuristic to overestimate someone's
146+ // grade. This is because we deem it generally less beneficial to be in a lower grade (because in practice the older
147+ // students usually have priority for attendance), increasing their chances of reaching out to correct the error.
148+ const endDate = getNextSemesterStart ( )
149+ const startDate = getCurrentSemesterStart ( )
150+
151+ if ( study === "MASTER" ) {
152+ const code = getSpecializationFromCode ( studySpecializations ?. [ 0 ] . code )
153+
140154 // If we have a new code that we have not seen, or for some other reason the code catches and returns UNKNOWN, we
141155 // emit a trace for it.
142156 if ( code === "UNKNOWN" ) {
@@ -150,18 +164,20 @@ export function getUserService(
150164 logger . info ( "Estimated end date for the master's programme to be %s" , endDate . toUTCString ( ) )
151165 return {
152166 type : "MASTER_STUDENT" ,
153- start : estimatedStudyStart ,
167+ start : startDate ,
154168 end : endDate ,
155169 specialization : code ,
170+ semester : estimatedStudyStart . semester ,
156171 }
157172 }
158173
159174 logger . info ( "Estimated end date for the bachelor's programme to be %s" , endDate . toUTCString ( ) )
160175 return {
161176 type : "BACHELOR_STUDENT" ,
162- start : estimatedStudyStart ,
177+ start : startDate ,
163178 end : endDate ,
164179 specialization : null ,
180+ semester : estimatedStudyStart . semester ,
165181 }
166182 }
167183
@@ -239,7 +255,9 @@ export function getUserService(
239255 if ( existingUser !== null ) {
240256 const membership = findActiveMembership ( existingUser )
241257
242- if ( membership !== null ) {
258+ // If the best active membership is KNIGHT, we attempt to discover a new membership for the user, in case they
259+ // can find a "better" membership this way.
260+ if ( membership !== null && membership . type !== "KNIGHT" ) {
243261 return existingUser
244262 }
245263
@@ -382,11 +400,21 @@ export function getUserService(
382400
383401 // We can only replace memberships if there is a new applicable one for the user
384402 if (
385- shouldReplaceMembership ( user . memberships , activeMembership , applicableMembership ) &&
386- applicableMembership !== null
403+ applicableMembership !== null &&
404+ shouldReplaceMembership ( user . memberships , activeMembership , applicableMembership )
387405 ) {
388- logger . info ( "Discovered applicable membership for user %s: %o" , user . id , applicableMembership )
389- await userRepository . createMembership ( handle , user . id , applicableMembership )
406+ // We make sure the membership is active before creating it. If it is not active, something has gone
407+ // wrong in our logic.
408+ if ( isMembershipActive ( applicableMembership ) ) {
409+ logger . info ( "Discovered applicable membership for user %s: %o" , user . id , applicableMembership )
410+ await userRepository . createMembership ( handle , user . id , applicableMembership )
411+ } else {
412+ logger . warn (
413+ "Discovered and discarded invalid membership for user %s: %o" ,
414+ user . id ,
415+ applicableMembership
416+ )
417+ }
390418 }
391419 }
392420 } finally {
@@ -440,6 +468,15 @@ export function getUserService(
440468 }
441469}
442470
471+ /**
472+ * Determine if we should replace a previous membership with a new one.
473+ *
474+ * This is true if:
475+ * - There is no previous membership.
476+ * - The membership is not a duplicate of an existing membership (active or inactive).
477+ * - The previous membership is not active, and the next one is active.
478+ * - The next membership has a semester greater than or equal to the previous one.
479+ */
443480function shouldReplaceMembership (
444481 allMemberships : Membership [ ] ,
445482 previous : Membership | null ,
@@ -458,16 +495,20 @@ function shouldReplaceMembership(
458495 return true
459496 }
460497
461- if ( previous . type === "BACHELOR_STUDENT" && next . type === "MASTER_STUDENT" ) {
498+ if ( ! isMembershipActive ( previous ) && isMembershipActive ( next ) ) {
462499 return true
463500 }
464501
465- return previous . type === "SOCIAL_MEMBER"
502+ // Returns true if the next semester is greater than or equal to the previous semester
503+ return ( next . semester ?? - Infinity ) - ( previous . semester ?? - Infinity ) >= 0
466504}
467505
468506function areMembershipsEqual ( a : Membership , b : MembershipWrite ) {
507+ const isSameStart = isSameDay ( a . start , b . start )
508+ const isSameEnd = ( a . end === null && b . end === null ) || ( a . end !== null && b . end !== null && isSameDay ( a . end , b . end ) )
509+
469510 return (
470- isSameDay ( a . start , b . start ) && isSameDay ( a . end , b . end ) && a . specialization === b . specialization && a . type === b . type
511+ isSameStart && isSameEnd && a . specialization === b . specialization && a . type === b . type && a . semester === b . semester
471512 )
472513}
473514
0 commit comments