Skip to content

Commit 4f839e9

Browse files
committed
wip: update membership system
1 parent 42b5bea commit 4f839e9

File tree

4 files changed

+112
-34
lines changed

4 files changed

+112
-34
lines changed

apps/rpc/src/modules/user/membership-service.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import { differenceInMonths, subYears } from "date-fns"
33
import invariant from "tiny-invariant"
44
import type { NTNUGroup } from "../feide/feide-groups-repository"
55
import { getLogger } from "@dotkomonline/logger"
6+
import { getCurrentUTC } from "@dotkomonline/utils"
7+
import type { TZDate } from "@date-fns/tz"
68

79
export interface MembershipService {
8-
findMasterStartYearDelta(courses: NTNUGroup[]): number
9-
findBachelorStartYearDelta(courses: NTNUGroup[]): number
10+
findEstimatedStudyStart(
11+
study: "BACHELOR" | "MASTER",
12+
courses: NTNUGroup[]
13+
): { start: TZDate; semester: number; grade: number }
1014
}
1115

1216
const BACHELOR_STUDY_PLAN = [
@@ -84,9 +88,9 @@ export function getMembershipService(): MembershipService {
8488
}
8589

8690
/**
87-
* Find the approximate start year based on a student's courses against a hard-coded set of courses.
91+
* Find the approximate semester based on a student's courses against a hard-coded set of courses.
8892
*/
89-
function findApproximateStartYear(studentCourses: NTNUGroup[], courseSet: StudyPlanCourseSet): number {
93+
function findEstimatedSemester(courseSet: StudyPlanCourseSet, studentCourses: NTNUGroup[]): number {
9094
logger.info("Searching for applicable membership based on courses %o and study plan %o", studentCourses, courseSet)
9195
let largestSemester = 0
9296
for (let i = 0; i < courseSet.length; i++) {
@@ -168,21 +172,37 @@ export function getMembershipService(): MembershipService {
168172
}
169173
}
170174

171-
// Give the value back in years (two school semesters in a year).
172-
// We use Math#round because it will give us the correct year delta:
173-
// Year 1 fall (value 0) : round(0 / 2) = 0 (Start year = current year)
174-
// Year 1 spring (value 1): round(1 / 2) = 1 (Start year = current - 1, since spring is in the next calendar year)
175-
// Year 2 fall (value 2) : round(2 / 2) = 1 (Start year = current - 1)
176-
// Year 2 spring (value 3): round(3 / 2) = 2 (Start year = current - 2)
177-
return Math.round(largestSemester / 2)
175+
return largestSemester
178176
}
179-
return {
180-
findMasterStartYearDelta(courses) {
181-
return findApproximateStartYear(courses, MASTER_STUDY_PLAN)
182-
},
183177

184-
findBachelorStartYearDelta(courses) {
185-
return findApproximateStartYear(courses, BACHELOR_STUDY_PLAN)
178+
return {
179+
findEstimatedStudyStart(study, courses) {
180+
const studyPlan = study === "MASTER" ? MASTER_STUDY_PLAN : BACHELOR_STUDY_PLAN
181+
const semester = findEstimatedSemester(studyPlan, courses)
182+
183+
// We use Math#round because it will give us the correct year delta:
184+
// Year 1 autumn (value 0) : round(0 / 2) = 0 (Start year = current year)
185+
// Year 1 spring (value 1): round(1 / 2) = 1 (Start year = current - 1, since spring is in the next calendar year)
186+
// Year 2 autumn (value 2) : round(2 / 2) = 1 (Start year = current - 1)
187+
// Year 2 spring (value 3): round(3 / 2) = 2 (Start year = current - 2)
188+
// ...
189+
const yearOffset = Math.round(semester / 2)
190+
const start = subYears(getAcademicStart(getCurrentUTC()), yearOffset)
191+
192+
// A school year consists of two semesters (Autumn and Spring). So this formula will give us the year:
193+
// Year 1 autumn (value 0): floor(0 / 2) + 1 = 1 (Year 1)
194+
// Year 1 spring (value 1): floor(1 / 2) + 1 = 1 (Year 1)
195+
// Year 2 autumn (value 2): floor(2 / 2) + 1 = 2 (Year 2)
196+
// Year 2 spring (value 3): floor(3 / 2) + 1 = 2 (Year 2)
197+
// Year 3 autumn (value 4): floor(4 / 2) + 1 = 3 (Year 3)
198+
// ...
199+
const grade = Math.floor(semester / 2) + 1
200+
201+
return {
202+
semester,
203+
start,
204+
grade,
205+
}
186206
},
187207
}
188208
}

apps/rpc/src/modules/user/user-service.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
findActiveMembership,
1919
getAcademicStart,
2020
getNextAcademicStart,
21+
getNextSemesterStart,
22+
getCurrentSemesterStart,
2123
} from "@dotkomonline/types"
2224
import { createS3PresignedPost, getCurrentUTC, slugify } from "@dotkomonline/utils"
2325
import { trace } from "@opentelemetry/api"
@@ -120,15 +122,31 @@ export function getUserService(
120122
}
121123

122124
// 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()
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()
132150

133151
// Master's programme takes precedence over bachelor's programme.
134152
if (masterProgramme !== undefined) {
@@ -148,18 +166,22 @@ export function getUserService(
148166
logger.info("Estimated end date for the master's programme to be %s", endDate.toUTCString())
149167
return {
150168
type: "MASTER_STUDENT",
151-
start: estimatedStudyStart,
169+
start: startDate,
152170
end: endDate,
153171
specialization: code,
172+
grade: estimatedStudyStart.grade,
173+
semester: estimatedStudyStart.semester,
154174
}
155175
}
156176

157177
logger.info("Estimated end date for the bachelor's programme to be %s", endDate.toUTCString())
158178
return {
159179
type: "BACHELOR_STUDENT",
160-
start: estimatedStudyStart,
180+
start: startDate,
161181
end: endDate,
162182
specialization: null,
183+
grade: estimatedStudyStart.grade,
184+
semester: estimatedStudyStart.semester,
163185
}
164186
}
165187

packages/db/prisma/schema.prisma

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,18 @@ enum MembershipSpecialization {
4545

4646
model Membership {
4747
id String @id @default(uuid())
48-
userId String @map("user_id")
49-
user User @relation(fields: [userId], references: [id])
5048
type MembershipType
5149
specialization MembershipSpecialization? @default(UNKNOWN)
5250
start DateTime @db.Timestamptz(3)
5351
end DateTime @db.Timestamptz(3)
52+
/// The study grade (year) of the student. Freshman (first year) means grade 1.
53+
grade Int
54+
/// The semester the student is in. 0-indexed. This value is meant to be used to calculate study start date easier
55+
/// with membership start.
56+
semester Int
57+
58+
userId String @map("user_id")
59+
user User @relation(fields: [userId], references: [id])
5460
5561
@@map("membership")
5662
}

packages/types/src/user.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { TZDate } from "@date-fns/tz"
1+
import { TZDate } from "@date-fns/tz"
22
import { schemas } from "@dotkomonline/db/schemas"
33
import { getCurrentUTC, slugify } from "@dotkomonline/utils"
4-
import { addYears, isAfter, isBefore, setMonth, startOfMonth } from "date-fns"
4+
import { addYears, interval, isAfter, isBefore, isWithinInterval, setMonth, startOfMonth } from "date-fns"
55
import { z } from "zod"
66
import { buildSearchFilter } from "./filters"
77

@@ -154,10 +154,15 @@ export function getSpecializationName(specialization: MembershipSpecialization)
154154
}
155155
}
156156

157+
const JANUARY = 0
158+
const AUGUST = 7
159+
const getSpringSemesterStart = (year: number) => new TZDate(year, JANUARY, 1)
160+
const getAutumnSemesterStart = (year: number) => new TZDate(year, AUGUST, 1)
161+
157162
/** Get the start of the academic year, which is by our convention August 1st. */
158163
export function getAcademicStart(date: TZDate | Date): TZDate {
159-
// August is the 8th month, so we set the month to 7 (0-indexed)
160-
return startOfMonth(setMonth(date, 7))
164+
// August 1st -- <year>-08-01T00:00:00.000Z
165+
return new TZDate(date.getFullYear(), AUGUST, 1)
161166
}
162167

163168
export function getNextAcademicStart(): TZDate {
@@ -167,6 +172,31 @@ export function getNextAcademicStart(): TZDate {
167172
return isBeforeAugust ? firstAugust : addYears(firstAugust, 1)
168173
}
169174

175+
export function getCurrentSemesterStart(): TZDate {
176+
const now = getCurrentUTC()
177+
const year = now.getFullYear()
178+
179+
const springSemesterStart = getSpringSemesterStart(year)
180+
const autumnSemesterStart = getAutumnSemesterStart(year)
181+
182+
if (isWithinInterval(now, { start: springSemesterStart, end: autumnSemesterStart })) {
183+
return springSemesterStart
184+
}
185+
186+
return autumnSemesterStart
187+
}
188+
189+
export function getNextSemesterStart(): TZDate {
190+
const now = getCurrentUTC()
191+
const autumnSemesterStart = getAutumnSemesterStart(now.getFullYear())
192+
193+
if (isBefore(now, autumnSemesterStart)) {
194+
return autumnSemesterStart
195+
}
196+
197+
return getSpringSemesterStart(addYears(now, 1).getFullYear())
198+
}
199+
170200
/**
171201
* Calculates how many academic years have passed since the start date.
172202
* If start is "last August" (current academic year), returns 1.

0 commit comments

Comments
 (0)