Skip to content

Commit ca0fe05

Browse files
committed
feat: add Knight registration to new membership system
1 parent e0e4b09 commit ca0fe05

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,
@@ -218,7 +219,11 @@ const onRegisterChangeProcedure = procedure
218219
.use(withDatabaseTransaction())
219220
.subscription(async function* ({ input, ctx, signal }) {
220221
for await (const [data] of on(ctx.eventEmitter, "attendance:register-change", { signal })) {
221-
const attendeeUpdateData = data as { attendee: Attendee; status: "registered" | "deregistered" }
222+
const attendeeUpdateData = data as {
223+
attendee: Attendee
224+
status: "registered" | "deregistered"
225+
newAttendancePool: AttendancePool | undefined
226+
}
222227

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

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

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

546+
// This will deprioritize Knight memberships, meaning that if this returns a Knight membership, it is their only
547+
// active membership.
548+
const membership = findActiveMembership(user)
549+
550+
// This makes Knights only respect registration end. This requires a lot of trust, but the Knights are Online's
551+
// most trusted and devoted members throughout the years. The reason we give them so much lenience is to make it
552+
// simple for both us, the developers, and the event organizers. In practice, it's extremely uncommon for Knights
553+
// who don't have any other membership (aren't active students) to register to events. This usually happens once a
554+
// year, for an event called "Immatrikuleringsball" ("Immball"), and every five years for the anniversary.
555+
// Therefore, we give the Knights so much leniency to avoid having any frontend options for the Knights.
556+
if (membership?.type === "KNIGHT") {
557+
options.overrideTurnstileCheck = true
558+
options.ignoreRegisteredToParent = true
559+
options.ignoreRegistrationWindow = true
560+
}
561+
546562
if (!turnstileCheckResult) {
547563
if (!options.overrideTurnstileCheck) {
548564
return { cause: "INVALID_TURNSTILE_TOKEN", success: false }
@@ -592,16 +608,29 @@ export function getAttendanceService(
592608
}
593609
}
594610

595-
const membership = findActiveMembership(user)
596-
597611
if (membership === null) {
598612
return { cause: "MISSING_MEMBERSHIP", success: false }
599613
}
600614

601615
// This is a "free" check that does zero roundtrips against the database, despite having a rather large piece of
602616
// code associated with it.
603617
let applicablePool: AttendancePool | null = null
604-
if (options.overriddenAttendancePoolId !== null) {
618+
619+
if (membership.type === "KNIGHT") {
620+
// Knights should register to their own pool, and this pool might not exist before they register. "Ridder" is
621+
// the Norwegian name for Knights.
622+
applicablePool = attendance.pools.find((p) => p.title === "Ridder") ?? {
623+
id: crypto.randomUUID(),
624+
attendanceId,
625+
capacity: 0,
626+
yearCriteria: [],
627+
title: "Ridder",
628+
createdAt: new Date(),
629+
updatedAt: new Date(),
630+
mergeDelayHours: null,
631+
taskId: null,
632+
}
633+
} else if (options.overriddenAttendancePoolId !== null) {
605634
const pool = attendance.pools.find((p) => p.id === options.overriddenAttendancePoolId)
606635

607636
if (pool === undefined) {
@@ -678,6 +707,16 @@ export function getAttendanceService(
678707
success,
679708
})
680709

710+
let createdAttendancePool: AttendancePool | null = null
711+
712+
if (pool.title === "Ridder") {
713+
const poolExists = attendance.pools.some((p) => p.id === pool.id)
714+
715+
if (!poolExists) {
716+
createdAttendancePool = await attendanceRepository.createAttendancePool(handle, attendance.id, null, pool)
717+
}
718+
}
719+
681720
const poolAttendees = attendance.attendees.filter((a) => a.attendancePoolId === pool.id && a.reserved)
682721
const isImmediateReservation =
683722
(!isFuture(reservationActiveAt) && (pool.capacity === 0 || poolAttendees.length < pool.capacity)) ||
@@ -739,6 +778,7 @@ export function getAttendanceService(
739778
eventEmitter.emit("attendance:register-change", {
740779
attendee,
741780
status: "registered",
781+
newAttendancePool: createdAttendancePool ?? undefined,
742782
})
743783
logger.info(
744784
"Attendee(ID=%s,UserID=%s) named %s has registered (effective %s) for Event(ID=%s) named %s with options: %o",
@@ -882,6 +922,7 @@ export function getAttendanceService(
882922
eventEmitter.emit("attendance:register-change", {
883923
attendee,
884924
status: "deregistered",
925+
newAttendancePool: undefined,
885926
})
886927
logger.info(
887928
"Attendee(ID=%s,UserID=%s) named %s has deregistered from Event(ID=%s) named %s with options: %o",
@@ -1722,6 +1763,7 @@ function validateAttendanceWrite(data: AttendanceWrite) {
17221763
}
17231764
}
17241765

1766+
// NOTE: This should allow creating attendance pools with no year criteria
17251767
function validateAttendancePoolWrite(data: AttendancePoolWrite) {
17261768
if (data.mergeDelayHours !== null && (data.mergeDelayHours < 0 || data.mergeDelayHours > 48)) {
17271769
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)