Skip to content

Commit 9ca2514

Browse files
committed
feat: add Knight registration to new membership system
1 parent ba3fdba commit 9ca2514

File tree

6 files changed

+108
-25
lines changed

6 files changed

+108
-25
lines changed

apps/rpc/src/modules/event/attendance-router.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { on } from "node:events"
22
import { TZDate } from "@date-fns/tz"
33
import {
4+
type AttendancePool,
45
AttendancePoolSchema,
56
AttendancePoolWriteSchema,
67
AttendanceSchema,
@@ -217,7 +218,11 @@ const onRegisterChangeProcedure = procedure
217218
.use(withDatabaseTransaction())
218219
.subscription(async function* ({ input, ctx, signal }) {
219220
for await (const [data] of on(ctx.eventEmitter, "attendance:register-change", { signal })) {
220-
const attendeeUpdateData = data as { attendee: Attendee; status: "registered" | "deregistered" }
221+
const attendeeUpdateData = data as {
222+
attendee: Attendee
223+
status: "registered" | "deregistered"
224+
newAttendancePool: AttendancePool | undefined
225+
}
221226

222227
if (attendeeUpdateData.attendee.attendanceId !== input.attendanceId) {
223228
continue

apps/rpc/src/modules/event/attendance-service.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,22 @@ export function getAttendanceService(
522522
// skipped.
523523
const bypassedChecks: RegistrationBypassCause[] = []
524524

525+
// This will deprioritize Knight memberships, meaning that if this returns a Knight membership, it is their only
526+
// active membership.
527+
const membership = findActiveMembership(user)
528+
529+
// This makes Knights only respect registration end. This requires a lot of trust, but the Knights are Online's
530+
// most trusted and devoted members throughout the years. The reason we give them so much lenience is to make it
531+
// simple for both us, the developers, and the event organizers. In practice, it's extremely uncommon for Knights
532+
// who don't have any other membership (aren't active students) to register to events. This usually happens once a
533+
// year, for an event called "Immatrikuleringsball" ("Immball"), and every five years for the anniversary.
534+
// Therefore, we give the Knights so much leniency to avoid having any frontend options for the Knights.
535+
if (membership?.type === "KNIGHT") {
536+
options.overrideTurnstileCheck = true
537+
options.ignoreRegisteredToParent = true
538+
options.ignoreRegistrationWindow = true
539+
}
540+
525541
if (!turnstileCheckResult) {
526542
if (!options.overrideTurnstileCheck) {
527543
return { cause: "INVALID_TURNSTILE_TOKEN", success: false }
@@ -571,16 +587,29 @@ export function getAttendanceService(
571587
}
572588
}
573589

574-
const membership = findActiveMembership(user)
575-
576590
if (membership === null) {
577591
return { cause: "MISSING_MEMBERSHIP", success: false }
578592
}
579593

580594
// This is a "free" check that does zero roundtrips against the database, despite having a rather large piece of
581595
// code associated with it.
582596
let applicablePool: AttendancePool | null = null
583-
if (options.overriddenAttendancePoolId !== null) {
597+
598+
if (membership.type === "KNIGHT") {
599+
// Knights should register to their own pool, and this pool might not exist before they register. "Ridder" is
600+
// the Norwegian name for Knights.
601+
applicablePool = attendance.pools.find((p) => p.title === "Ridder") ?? {
602+
id: crypto.randomUUID(),
603+
attendanceId,
604+
capacity: 0,
605+
yearCriteria: [],
606+
title: "Ridder",
607+
createdAt: new Date(),
608+
updatedAt: new Date(),
609+
mergeDelayHours: null,
610+
taskId: null,
611+
}
612+
} else if (options.overriddenAttendancePoolId !== null) {
584613
const pool = attendance.pools.find((p) => p.id === options.overriddenAttendancePoolId)
585614

586615
if (pool === undefined) {
@@ -657,6 +686,16 @@ export function getAttendanceService(
657686
success,
658687
})
659688

689+
let createdAttendancePool: AttendancePool | null = null
690+
691+
if (pool.title === "Ridder") {
692+
const poolExists = attendance.pools.some((p) => p.id === pool.id)
693+
694+
if (!poolExists) {
695+
createdAttendancePool = await attendanceRepository.createAttendancePool(handle, attendance.id, null, pool)
696+
}
697+
}
698+
660699
const poolAttendees = attendance.attendees.filter((a) => a.attendancePoolId === pool.id && a.reserved)
661700
const isImmediateReservation =
662701
(!isFuture(reservationActiveAt) && (pool.capacity === 0 || poolAttendees.length < pool.capacity)) ||
@@ -718,6 +757,7 @@ export function getAttendanceService(
718757
eventEmitter.emit("attendance:register-change", {
719758
attendee,
720759
status: "registered",
760+
newAttendancePool: createdAttendancePool ?? undefined,
721761
})
722762
logger.info(
723763
"Attendee(ID=%s,UserID=%s) named %s has registered (effective %s) for Event(ID=%s) named %s with options: %o",
@@ -861,6 +901,7 @@ export function getAttendanceService(
861901
eventEmitter.emit("attendance:register-change", {
862902
attendee,
863903
status: "deregistered",
904+
newAttendancePool: undefined,
864905
})
865906
logger.info(
866907
"Attendee(ID=%s,UserID=%s) named %s has deregistered from Event(ID=%s) named %s with options: %o",
@@ -1694,6 +1735,7 @@ function validateAttendanceWrite(data: AttendanceWrite) {
16941735
}
16951736
}
16961737

1738+
// NOTE: This should allow creating attendance pools with no year criteria
16971739
function validateAttendancePoolWrite(data: AttendancePoolWrite) {
16981740
if (data.mergeDelayHours !== null && (data.mergeDelayHours < 0 || data.mergeDelayHours > 48)) {
16991741
throw new InvalidArgumentError("Merge delay for pool must be between 0 and 48 hours")

apps/web/src/app/arrangementer/components/AttendanceCard/AttendanceCard.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export const AttendanceCard = ({
114114
onConnectionStateChange: (state) => {
115115
setTRPCSSERegisterChangeConnectionState(state.state)
116116
},
117-
onData: ({ status, attendee }) => {
117+
onData: ({ status, attendee, newAttendancePool }) => {
118118
// If the attendee is not the current user, we can update the state
119119
queryClient.setQueryData(
120120
trpc.event.attendance.getAttendance.queryOptions({ id: attendance?.id }).queryKey,
@@ -135,9 +135,12 @@ export const AttendanceCard = ({
135135
return oldData
136136
}
137137

138+
const updatedPools = newAttendancePool ? [...oldData.pools, newAttendancePool] : oldData.pools
139+
138140
return {
139141
...oldData,
140142
attendees: [...oldData.attendees, attendee],
143+
pools: updatedPools,
141144
}
142145
}
143146
)
@@ -190,6 +193,7 @@ export const AttendanceCard = ({
190193
console.error("No turnstile token, cannot register")
191194
return
192195
}
196+
193197
registerMutation.mutate({ attendanceId: attendance.id, turnstileToken })
194198
}
195199
const deregisterForAttendance = (deregisterReason: DeregisterReasonFormResult) => {

apps/web/src/app/arrangementer/components/AttendanceCard/MainPoolCard.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,30 @@ export const MainPoolCard: FC<MainPoolCardProps> = ({ attendance, user, authoriz
114114
)
115115
}
116116

117-
const pool = getAttendablePool(attendance, user)
117+
let pool = getAttendablePool(attendance, user)
118118

119119
if (!pool) {
120-
return (
121-
<div className={cardClassname}>
122-
<Text>Du kan ikke melde deg på dette arrangementet</Text>
123-
</div>
124-
)
120+
// Knights will create their own pool when registering if it does not exist. For simplicity, we just mock the pool
121+
// as this data is just for visualizing the pool.
122+
if (membership?.type === "KNIGHT") {
123+
pool = {
124+
id: "11111111-1111-1111-1111-111111111111",
125+
title: "Ridder",
126+
attendanceId: attendance.id,
127+
mergeDelayHours: null,
128+
updatedAt: new Date(),
129+
createdAt: new Date(),
130+
yearCriteria: [],
131+
capacity: 0,
132+
taskId: null,
133+
}
134+
} else {
135+
return (
136+
<div className={cardClassname}>
137+
<Text>Du kan ikke melde deg på dette arrangementet</Text>
138+
</div>
139+
)
140+
}
125141
}
126142

127143
const unreservedAttendeeCount = getUnreservedAttendeeCount(attendance, pool.id)

apps/web/src/app/arrangementer/components/AttendanceCard/RegistrationButton.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,15 @@ const getDisabledText = (
4747
isSuspended: boolean,
4848
registeredToParentEvent: boolean | null,
4949
reservedToParentEvent: boolean | null,
50-
hasTurnstileToken: boolean
50+
hasTurnstileToken: boolean,
51+
isKnight: boolean
5152
) => {
5253
if (!isLoggedIn) {
5354
return "Du må være innlogget for å melde deg på"
5455
}
5556

5657
const isAttending = attendee !== null
5758

58-
if (!hasTurnstileToken && !isAttending) {
59-
return "Du må bekrefte at du ikke er en robot"
60-
}
61-
6259
if (isAttending) {
6360
if (isPastDeregisterDeadline && attendee.reserved) {
6461
return "Avmeldingsfristen har utløpt"
@@ -73,24 +70,33 @@ const getDisabledText = (
7370
if (isSuspended) {
7471
return "Du er suspendert fra Online"
7572
}
76-
if (!hasMembership) {
77-
return "Du må ha registrert medlemskap for å melde deg på"
78-
}
79-
if (status === "NotOpened") {
80-
return "Påmeldinger har ikke åpnet"
81-
}
8273
if (status === "Closed") {
8374
return "Påmeldingen er stengt"
8475
}
76+
77+
// Knights ("Riddere") bypass the remaining checks in the backend
78+
if (isKnight) {
79+
return null
80+
}
81+
82+
if (!hasMembership) {
83+
return "Du må ha registrert medlemskap for å melde deg på"
84+
}
8585
if (!pool) {
8686
return "Du har ingen påmeldingsgruppe"
8787
}
88+
if (status === "NotOpened") {
89+
return "Påmeldinger har ikke åpnet"
90+
}
8891
if (registeredToParentEvent === false) {
8992
return "Du er ikke påmeldt foreldrearrangementet"
9093
}
9194
if (reservedToParentEvent === false && registeredToParentEvent === true) {
9295
return "Du er i kø på foreldrearrangementet"
9396
}
97+
if (!hasTurnstileToken) {
98+
return "Du må bekrefte at du ikke er en robot"
99+
}
94100

95101
return null
96102
}
@@ -125,7 +131,10 @@ export const RegistrationButton: FC<RegistrationButtonProps> = ({
125131
const attendee = getAttendee(attendance, user)
126132
const pool = getAttendablePool(attendance, user)
127133
const attendanceStatus = getAttendanceStatus(attendance)
128-
const hasMembership = user !== null && Boolean(findActiveMembership(user))
134+
135+
const membership = user !== null ? findActiveMembership(user) : null
136+
const hasMembership = membership !== null
137+
const isKnight = membership?.type === "KNIGHT"
129138

130139
// TODO: dont calculate this in frontend
131140
const actualDeregisterDeadline = chargeScheduleDate
@@ -158,12 +167,13 @@ export const RegistrationButton: FC<RegistrationButtonProps> = ({
158167
isSuspended,
159168
registeredToParentEvent,
160169
reservedToParentEvent,
161-
hasTurnstileToken
170+
hasTurnstileToken,
171+
isKnight
162172
)
163173
const disabled = Boolean(disabledText)
164174

165175
const buttonContent = isLoading ? (
166-
<IconLoader2 className="size-6 animate-spin py-2" />
176+
<IconLoader2 className="size-10 animate-spin py-2" />
167177
) : (
168178
<div
169179
className={cn(

packages/types/src/attendance.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ export function getAttendanceCapacity(attendance: Attendance | AttendanceSummary
130130

131131
export function isAttendable(user: User, pool: AttendancePool) {
132132
const membership = findActiveMembership(user)
133+
134+
// Knights can always attend pools named "Ridder". This is a hard-coded name used only for Knight attendance.
135+
if (membership?.type === "KNIGHT") {
136+
return pool.title === "Ridder"
137+
}
138+
133139
const grade = membership?.semester != null ? getStudyGrade(membership.semester) : null
134140

135141
if (grade === null) {

0 commit comments

Comments
 (0)