Skip to content

Commit 474ae6b

Browse files
committed
wip: update membership system
1 parent 2593d7d commit 474ae6b

File tree

9 files changed

+293
-102
lines changed

9 files changed

+293
-102
lines changed

apps/dashboard/src/app/(internal)/brukere/components/membership-form.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import {
88
MembershipWriteSchema,
99
getMembershipTypeName,
1010
getSpecializationName,
11+
getNextSemesterStart,
12+
getCurrentSemesterStart,
1113
} from "@dotkomonline/types"
12-
import { getCurrentUTC } from "@dotkomonline/utils"
13-
import { addYears, isBefore } from "date-fns"
14+
import { isBefore } from "date-fns"
1415
import type { z } from "zod"
16+
import { createNumberInput } from "@/components/forms/NumberInput"
17+
import { Code, Stack, Text } from "@mantine/core"
1518

1619
export const MembershipWriteFormSchema = MembershipWriteSchema.superRefine((data, ctx) => {
17-
if (isBefore(data.end, data.start)) {
20+
if (data.end && isBefore(data.end, data.start)) {
1821
ctx.addIssue({
1922
code: "custom",
2023
message: "Sluttdato må være etter startdato",
@@ -26,13 +29,14 @@ export const MembershipWriteFormSchema = MembershipWriteSchema.superRefine((data
2629
type MembershipWriteFormSchema = z.infer<typeof MembershipWriteFormSchema>
2730

2831
const DEFAULT_VALUES: Partial<MembershipWriteFormSchema> = {
29-
start: getCurrentUTC(),
30-
end: addYears(getCurrentUTC(), 1),
32+
start: getCurrentSemesterStart(),
33+
end: getNextSemesterStart(),
3134
specialization: null,
35+
semester: 0,
3236
}
3337

3438
interface UseMembershipWriteFormProps {
35-
onSubmit(data: z.infer<typeof MembershipWriteFormSchema>): void
39+
onSubmit(data: MembershipWriteFormSchema): void
3640
defaultValues?: Partial<MembershipWrite>
3741
label?: string
3842
}
@@ -59,6 +63,7 @@ export const useMembershipWriteForm = ({
5963
}),
6064
specialization: createSelectInput({
6165
label: "Spesialisering",
66+
description: "Masterspesialisering",
6267
required: false,
6368
clearable: true,
6469
placeholder: "Velg spesialisering",
@@ -68,6 +73,7 @@ export const useMembershipWriteForm = ({
6873
value: specialization,
6974
label: getSpecializationName(specialization) ?? specialization,
7075
})),
76+
disabled: false,
7177
}),
7278
start: createDateTimeInput({
7379
label: "Startdato",
@@ -77,6 +83,40 @@ export const useMembershipWriteForm = ({
7783
label: "Sluttdato",
7884
required: true,
7985
}),
86+
semester: createNumberInput({
87+
label: "Semester",
88+
description: (
89+
<Stack gap="xs">
90+
<Text size="xs" c="dimmed">
91+
Hvilket semester medlemskapet innebærer. 0-indeksert.
92+
</Text>
93+
<Stack gap="0.25rem">
94+
<Text size="xs" c="dimmed">
95+
<Code>0</Code> → 1. semester (1. årstrinn)
96+
</Text>
97+
<Text size="xs" c="dimmed">
98+
<Code>1</Code> → 2. semester (1. årstrinn)
99+
</Text>
100+
<Text size="xs" c="dimmed">
101+
<Code>2</Code> → 3. semester (2. årstrinn)
102+
</Text>
103+
<Text size="xs" c="dimmed">
104+
...
105+
</Text>
106+
<Text size="xs" c="dimmed">
107+
<Code>8</Code> → 9. semester (5. årstrinn)
108+
</Text>
109+
<Text size="xs" c="dimmed">
110+
<Code>9</Code> → 10. semester (5. årstrinn)
111+
</Text>
112+
</Stack>
113+
</Stack>
114+
),
115+
required: false,
116+
min: 0,
117+
max: 9,
118+
allowDecimal: false,
119+
}),
80120
},
81121
})
82122
}

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: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import type { PresignedPost } from "@aws-sdk/s3-presigned-post"
44
import type { DBHandle } from "@dotkomonline/db"
55
import { getLogger } from "@dotkomonline/logger"
66
import {
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"
2323
import { trace } from "@opentelemetry/api"
2424
import type { ManagementClient } from "auth0"
25-
import { isSameDay, subYears } from "date-fns"
25+
import { isSameDay } from "date-fns"
2626
import { AlreadyExistsError, IllegalStateError, InvalidArgumentError, NotFoundError } from "../../error"
2727
import type { Pageable } from "../../query"
2828
import 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+
*/
415452
function 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

440478
function 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

apps/web/next-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
/// <reference path="./.next/types/routes.d.ts" />
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
Warnings:
3+
4+
- The values [OTHER] on the enum `membership_type` will be removed. If these variants are still used in the database, this will fail.
5+
6+
*/
7+
-- AlterEnum
8+
BEGIN;
9+
CREATE TYPE "membership_type_new" AS ENUM ('BACHELOR_STUDENT', 'MASTER_STUDENT', 'PHD_STUDENT', 'KNIGHT', 'SOCIAL_MEMBER');
10+
ALTER TABLE "membership" ALTER COLUMN "type" TYPE "membership_type_new" USING ("type"::text::"membership_type_new");
11+
ALTER TYPE "membership_type" RENAME TO "membership_type_old";
12+
ALTER TYPE "membership_type_new" RENAME TO "membership_type";
13+
DROP TYPE "public"."membership_type_old";
14+
COMMIT;
15+
16+
-- AlterTable
17+
ALTER TABLE "membership" ADD COLUMN "semester" INTEGER,
18+
ALTER COLUMN "end" DROP NOT NULL;

0 commit comments

Comments
 (0)