Skip to content

Commit ec1b4a5

Browse files
committed
feat: add featured events route
1 parent e737473 commit ec1b4a5

File tree

9 files changed

+276
-945
lines changed

9 files changed

+276
-945
lines changed

apps/rpc/src/modules/event/event-repository.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import type { DBHandle, Prisma } from "@dotkomonline/db"
1+
import type { DBHandle, Prisma, findFeaturedEvents } from "@dotkomonline/db"
22
import {
33
type AttendanceId,
4+
type BaseEvent,
5+
BaseEventSchema,
46
type CompanyId,
57
type DeregisterReason,
68
DeregisterReasonSchema,
@@ -40,6 +42,8 @@ const INCLUDE_COMPANY_AND_GROUPS = {
4042
export interface EventRepository {
4143
create(handle: DBHandle, data: EventWrite): Promise<Event>
4244
update(handle: DBHandle, eventId: EventId, data: Partial<EventWrite>): Promise<Event>
45+
updateEventAttendance(handle: DBHandle, eventId: EventId, attendanceId: AttendanceId): Promise<Event>
46+
updateEventParent(handle: DBHandle, eventId: EventId, parentEventId: EventId | null): Promise<Event>
4347
/**
4448
* Soft-delete an event by setting its status to "DELETED".
4549
*/
@@ -80,15 +84,17 @@ export interface EventRepository {
8084
page: Pageable
8185
): Promise<Event[]>
8286
findByParentEventId(handle: DBHandle, parentEventId: EventId): Promise<Event[]>
87+
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>
88+
findManyDeregisterReasonsWithEvent(handle: DBHandle, page: Pageable): Promise<DeregisterReasonWithEvent[]>
89+
// This cannot use `Pageable` due to raw query needing numerical offset and not cursor based pagination
90+
findFeaturedEvents(handle: DBHandle, offset: number, limit: number): Promise<BaseEvent[]>
91+
8392
addEventHostingGroups(handle: DBHandle, eventId: EventId, hostingGroupIds: Set<GroupId>): Promise<void>
84-
addEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
8593
deleteEventHostingGroups(handle: DBHandle, eventId: EventId, hostingGroupIds: Set<GroupId>): Promise<void>
94+
addEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
8695
deleteEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
87-
updateEventAttendance(handle: DBHandle, eventId: EventId, attendanceId: AttendanceId): Promise<Event>
88-
updateEventParent(handle: DBHandle, eventId: EventId, parentEventId: EventId | null): Promise<Event>
89-
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>
96+
9097
createDeregisterReason(handle: DBHandle, data: DeregisterReasonWrite): Promise<DeregisterReason>
91-
findManyDeregisterReasonsWithEvent(handle: DBHandle, page: Pageable): Promise<DeregisterReasonWithEvent[]>
9298
}
9399

94100
export function getEventRepository(): EventRepository {
@@ -309,6 +315,25 @@ export function getEventRepository(): EventRepository {
309315
)
310316
},
311317

318+
async findFeaturedEvents(handle, offset, limit) {
319+
// Events will primarily be ranked by their type in the following order (lower number is higher ranking):
320+
// 1. GENERAL_ASSEMBLY
321+
// 2. COMPANY, ACADEMIC
322+
// 3. SOCIAL, INTERNAL, OTHER, WELCOME
323+
//
324+
// Within each bucket they will be ranked like this:
325+
// 1. Event in future, registration open and not full AND attendance capacity is limited (>0)
326+
// 2. Event in future, registration not started yet (attendance capacity does not matter)
327+
// 3. Event in future, no attendance registration OR attendance capacity is unlimited (=0)
328+
// 4. Event in future, registration full (registration status does not matter)
329+
//
330+
// Past events are not featured. We would rather have no featured events than "stale" events.
331+
332+
const events = await handle.$queryRawTyped(findFeaturedEvents(offset, limit))
333+
334+
return parseOrReport(BaseEventSchema.array(), events)
335+
},
336+
312337
async addEventHostingGroups(handle, eventId, hostingGroupIds) {
313338
await handle.eventHostingGroup.createMany({
314339
data: hostingGroupIds

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { PresignedPost } from "@aws-sdk/s3-presigned-post"
22
import {
3+
AttendanceSchema,
34
AttendanceWriteSchema,
5+
BaseEventSchema,
46
CompanySchema,
57
EventFilterQuerySchema,
68
EventSchema,
@@ -153,7 +155,12 @@ const allEventsProcedure = procedure
153155
export type AllByAttendingUserIdInput = inferProcedureInput<typeof allByAttendingUserIdProcedure>
154156
export type AllByAttendingUserIdOutput = inferProcedureOutput<typeof allByAttendingUserIdProcedure>
155157
const allByAttendingUserIdProcedure = procedure
156-
.input(BasePaginateInputSchema.extend({ filter: EventFilterQuerySchema.optional(), id: UserSchema.shape.id }))
158+
.input(
159+
BasePaginateInputSchema.extend({
160+
filter: EventFilterQuerySchema.optional(),
161+
id: UserSchema.shape.id,
162+
})
163+
)
157164
.output(
158165
z.object({
159166
items: EventWithAttendanceSchema.array(),
@@ -172,7 +179,7 @@ const allByAttendingUserIdProcedure = procedure
172179

173180
const eventsWithAttendance = events.map((event) => ({
174181
event,
175-
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) || null,
182+
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) ?? null,
176183
}))
177184

178185
return {
@@ -244,7 +251,7 @@ const findChildEventsProcedure = procedure
244251
)
245252
return events.map((event) => ({
246253
event,
247-
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) || null,
254+
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) ?? null,
248255
}))
249256
})
250257

@@ -290,6 +297,33 @@ const findManyDeregisterReasonsWithEventProcedure = procedure
290297
}
291298
})
292299

300+
export type FindFeaturedEventsInput = inferProcedureInput<typeof findFeaturedEventsProcedure>
301+
export type FindFeaturedEventsOutput = inferProcedureOutput<typeof findFeaturedEventsProcedure>
302+
const findFeaturedEventsProcedure = procedure
303+
.input(
304+
z.object({
305+
offset: z.number().min(0).default(0),
306+
limit: z.number().min(1).max(100).default(10),
307+
})
308+
)
309+
.output(z.object({ event: BaseEventSchema, attendance: AttendanceSchema.nullable() }).array())
310+
.use(withDatabaseTransaction())
311+
.query(async ({ input, ctx }) => {
312+
const events = await ctx.eventService.findFeaturedEvents(ctx.handle, input.offset, input.limit)
313+
314+
const attendances = await ctx.attendanceService.getAttendancesByIds(
315+
ctx.handle,
316+
events.map((item) => item.attendanceId).filter((id) => id !== null)
317+
)
318+
319+
const eventsWithAttendance = events.map((event) => ({
320+
event,
321+
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) ?? null,
322+
}))
323+
324+
return eventsWithAttendance
325+
})
326+
293327
export type CreateFileUploadInput = inferProcedureInput<typeof createFileUploadProcedure>
294328
export type CreateFileUploadOutput = inferProcedureOutput<typeof createFileUploadProcedure>
295329
const createFileUploadProcedure = procedure

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { DBHandle } from "@dotkomonline/db"
44
import { getLogger } from "@dotkomonline/logger"
55
import type {
66
AttendanceId,
7+
BaseEvent,
78
CompanyId,
89
DeregisterReason,
910
DeregisterReasonWithEvent,
@@ -45,6 +46,7 @@ export interface EventService {
4546
findByParentEventId(handle: DBHandle, parentEventId: EventId): Promise<Event[]>
4647
findEventById(handle: DBHandle, eventId: EventId): Promise<Event | null>
4748
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>
49+
findFeaturedEvents(handle: DBHandle, offset: number, limit: number): Promise<BaseEvent[]>
4850
/**
4951
* Get an event by its id
5052
*
@@ -98,6 +100,10 @@ export function getEventService(
98100
return await eventRepository.findEventsWithUnansweredFeedbackFormByUserId(handle, userId)
99101
},
100102

103+
async findFeaturedEvents(handle, offset, limit) {
104+
return await eventRepository.findFeaturedEvents(handle, offset, limit)
105+
},
106+
101107
async getEventById(handle, eventId) {
102108
const event = await eventRepository.findById(handle, eventId)
103109
if (!event) {

apps/web/src/app/page.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { Attendance, Event, EventWithAttendance, UserId } from "@dotkomonli
1010
import { RichText, cn } from "@dotkomonline/ui"
1111
import { Text, Tilt, Title } from "@dotkomonline/ui"
1212
import { Button } from "@dotkomonline/ui"
13-
import { getCurrentUTC, slugify } from "@dotkomonline/utils"
13+
import { slugify } from "@dotkomonline/utils"
1414
import { IconArrowRight, IconCalendarEvent } from "@tabler/icons-react"
1515
import { formatDate } from "date-fns"
1616
import { nb } from "date-fns/locale"
@@ -20,17 +20,8 @@ import type { FC } from "react"
2020
export default async function App() {
2121
const [session, isStaff] = await Promise.all([auth.getServerSession(), server.user.isStaff.query()])
2222

23-
const { items: events } = await server.event.all.query({
24-
take: 3,
25-
filter: {
26-
byEndDate: {
27-
max: null,
28-
min: getCurrentUTC(),
29-
},
30-
excludingOrganizingGroup: ["velkom"],
31-
excludingType: isStaff ? [] : undefined,
32-
orderBy: "asc",
33-
},
23+
const events = await server.event.findFeaturedEvents.query({
24+
limit: 3,
3425
})
3526

3627
const featuredEvent = events[0] ?? null

packages/db/prisma.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ export default defineConfig({
1212
migrations: {
1313
path: "./prisma/migrations/",
1414
},
15+
typedSql: {
16+
path: "./prisma/sql",
17+
},
1518
})

packages/db/prisma/schema.prisma

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ generator client {
22
provider = "prisma-client"
33
output = "../generated/prisma"
44
5-
previewFeatures = ["relationJoins"]
5+
previewFeatures = ["relationJoins", "typedSql"]
66
}
77

88
generator zod {
@@ -220,13 +220,20 @@ enum EventStatus {
220220
}
221221

222222
enum EventType {
223-
SOCIAL @map("SOCIAL")
224-
ACADEMIC @map("ACADEMIC")
225-
COMPANY @map("COMPANY")
226-
// This is called "Generalforsamling" in Norwegian and happens twice a year.
223+
/// Generalforsamling
227224
GENERAL_ASSEMBLY @map("GENERAL_ASSEMBLY")
225+
/// Bedriftspresentasjon
226+
COMPANY @map("COMPANY")
227+
/// Kurs
228+
ACADEMIC @map("ACADEMIC")
229+
SOCIAL @map("SOCIAL")
230+
// These are for the rare occations we have events that are only open to committee members
228231
INTERNAL @map("INTERNAL")
229232
OTHER @map("OTHER")
233+
// These are for a committe called "velkom" and are special social events for new students.
234+
// These have a separate type because we have historically hid these from event lists to not
235+
// spam students that are not new with these events. In older versions of OnlineWeb these
236+
// were even treated as a completely separate event entity.
230237
WELCOME @map("WELCOME")
231238
232239
@@map("event_type")
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
-- @param {Int} $1:offset
2+
-- @param {Int} $2:limit
3+
4+
-- DOCS: https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/typedsql
5+
6+
-- This SQL query is used in EventRepository#findFeaturedEvents to find featured events, as this is too complex to do
7+
-- with Prisma's normal query API.
8+
9+
-- Events will primarily be ranked by their type in the following order (lower number is higher ranking):
10+
-- 1. GENERAL_ASSEMBLY
11+
-- 2. COMPANY, ACADEMIC
12+
-- 3. SOCIAL, INTERNAL, OTHER, WELCOME
13+
--
14+
-- Within each bucket they will be ranked like this:
15+
-- 1. Event in future, registration open and not full AND attendance capacities is limited (>0)
16+
-- 2. Event in future, registration not started yet (attendance capacities does not matter)
17+
-- 3. Event in future, no attendance registration OR attendance capacities is unlimited (=0)
18+
-- 4. Event in future, registration full (registration status does not matter)
19+
--
20+
-- Past events are not featured. We would rather have no featured events than "stale" events.
21+
22+
WITH
23+
capacities AS (
24+
SELECT
25+
"attendanceId",
26+
SUM("capacity") AS sum
27+
FROM "attendance_pool"
28+
GROUP BY "attendanceId"
29+
),
30+
31+
attendees AS (
32+
SELECT
33+
"attendanceId",
34+
COUNT(*) AS count
35+
FROM "attendee"
36+
GROUP BY "attendanceId"
37+
)
38+
39+
SELECT
40+
"event".*,
41+
COALESCE(capacities.sum, 0) AS "totalCapacity",
42+
COALESCE(attendees.count, 0) AS "attendeeCount",
43+
44+
-- 1,2,3: event type buckets
45+
CASE "event"."type"
46+
WHEN 'GENERAL_ASSEMBLY' THEN 1
47+
WHEN 'COMPANY' THEN 2
48+
WHEN 'ACADEMIC' THEN 2
49+
ELSE 3
50+
END AS "typeRank",
51+
52+
-- 1-4: registration buckets
53+
CASE
54+
-- 1. Future, registration open and not full AND capacities limited (> 0)
55+
WHEN "event"."attendanceId" IS NOT NULL
56+
AND NOW() BETWEEN attendance."registerStart" AND attendance."registerEnd"
57+
AND COALESCE(capacities.sum, 0) > 0
58+
AND COALESCE(attendees.count, 0) < COALESCE(capacities.sum, 0)
59+
THEN 1
60+
61+
-- 2. Future, registration not started yet (capacities doesn't matter)
62+
WHEN "event"."attendanceId" IS NOT NULL
63+
AND NOW() < attendance."registerStart"
64+
THEN 2
65+
66+
-- 3. Future, no registration OR unlimited capacities (total capacities = 0)
67+
WHEN "event"."attendanceId" IS NULL
68+
OR COALESCE(capacities.sum, 0) = 0
69+
THEN 3
70+
71+
-- 4. Future, registration full (status doesn't matter)
72+
WHEN "event"."attendanceId" IS NOT NULL
73+
AND COALESCE(capacities.sum, 0) > 0
74+
AND COALESCE(attendees.count, 0) >= COALESCE(capacities.sum, 0)
75+
THEN 4
76+
77+
-- Fallback: treat as bucket 4
78+
ELSE 4
79+
END AS "registrationBucket"
80+
81+
FROM "event"
82+
LEFT JOIN "attendance"
83+
ON "attendance"."id" = "event"."attendanceId"
84+
LEFT JOIN capacities
85+
ON capacities."attendanceId" = "event"."attendanceId"
86+
LEFT JOIN attendees
87+
ON attendees."attendanceId" = "event"."attendanceId"
88+
89+
WHERE
90+
"event"."status" = 'PUBLIC'
91+
-- Past events are not featured
92+
AND "event"."start" > NOW()
93+
94+
ORDER BY
95+
"typeRank" ASC,
96+
"registrationBucket" ASC,
97+
-- Tie breaker with earlier events first
98+
"event"."start" ASC
99+
100+
OFFSET $1
101+
LIMIT $2;

packages/types/src/event.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ import { GroupSchema } from "./group"
1212
* companies, attendance pools, and attendees.
1313
*/
1414

15-
export type EventId = Event["id"]
16-
export type EventType = Event["type"]
17-
export type EventStatus = Event["status"]
15+
export type BaseEvent = z.infer<typeof BaseEventSchema>
16+
export const BaseEventSchema = schemas.EventSchema.extend({})
17+
1818
export type Event = z.infer<typeof EventSchema>
1919
export const EventSchema = schemas.EventSchema.extend({
2020
companies: z.array(CompanySchema),
2121
hostingGroups: z.array(GroupSchema),
2222
})
23+
export type EventId = Event["id"]
24+
export type EventType = Event["type"]
25+
export type EventStatus = Event["status"]
2326

2427
export const EventTypeSchema = schemas.EventTypeSchema
2528
export const EventStatusSchema = schemas.EventStatusSchema

0 commit comments

Comments
 (0)