Skip to content

Commit 8d80c9a

Browse files
committed
feat: add featured events route
1 parent d6bf2d7 commit 8d80c9a

File tree

13 files changed

+429
-86
lines changed

13 files changed

+429
-86
lines changed

apps/rpc/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@dotkomonline/utils": "workspace:*",
3939
"@fastify/cors": "^11.0.0",
4040
"@opentelemetry/api": "^1.9.0",
41-
"@prisma/client": "^6.8.2",
41+
"@prisma/client": "^6.19.0",
4242
"@sentry/node": "^9.24.0",
4343
"@trpc/server": "11.4.4",
4444
"auth0": "^4.23.1",

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DBHandle } from "@dotkomonline/db"
2+
import { findFeaturedEvents } from "@dotkomonline/db"
23
import {
34
type AttendanceId,
45
type CompanyId,
@@ -12,6 +13,8 @@ import {
1213
type EventId,
1314
EventSchema,
1415
type EventWrite,
16+
type FeaturedEvent,
17+
FeaturedEventSchema,
1518
type GroupId,
1619
type UserId,
1720
} from "@dotkomonline/types"
@@ -41,6 +44,8 @@ const INCLUDE_COMPANY_AND_GROUPS = {
4144
export interface EventRepository {
4245
create(handle: DBHandle, data: EventWrite): Promise<Event>
4346
update(handle: DBHandle, eventId: EventId, data: Partial<EventWrite>): Promise<Event>
47+
updateEventAttendance(handle: DBHandle, eventId: EventId, attendanceId: AttendanceId): Promise<Event>
48+
updateEventParent(handle: DBHandle, eventId: EventId, parentEventId: EventId | null): Promise<Event>
4449
/**
4550
* Soft-delete an event by setting its status to "DELETED".
4651
*/
@@ -81,15 +86,17 @@ export interface EventRepository {
8186
page: Pageable
8287
): Promise<Event[]>
8388
findByParentEventId(handle: DBHandle, parentEventId: EventId): Promise<Event[]>
89+
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>
90+
findManyDeregisterReasonsWithEvent(handle: DBHandle, page: Pageable): Promise<DeregisterReasonWithEvent[]>
91+
// This cannot use `Pageable` due to raw query needing numerical offset and not cursor based pagination
92+
findFeaturedEvents(handle: DBHandle, offset: number, limit: number): Promise<FeaturedEvent["event"][]>
93+
8494
addEventHostingGroups(handle: DBHandle, eventId: EventId, hostingGroupIds: Set<GroupId>): Promise<void>
85-
addEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
8695
deleteEventHostingGroups(handle: DBHandle, eventId: EventId, hostingGroupIds: Set<GroupId>): Promise<void>
96+
addEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
8797
deleteEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
88-
updateEventAttendance(handle: DBHandle, eventId: EventId, attendanceId: AttendanceId): Promise<Event>
89-
updateEventParent(handle: DBHandle, eventId: EventId, parentEventId: EventId | null): Promise<Event>
90-
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>
98+
9199
createDeregisterReason(handle: DBHandle, data: DeregisterReasonWrite): Promise<DeregisterReason>
92-
findManyDeregisterReasonsWithEvent(handle: DBHandle, page: Pageable): Promise<DeregisterReasonWithEvent[]>
93100
}
94101

95102
export function getEventRepository(): EventRepository {
@@ -310,6 +317,27 @@ export function getEventRepository(): EventRepository {
310317
)
311318
},
312319

320+
async findFeaturedEvents(handle, offset, limit) {
321+
// This is a complex task to do, and we will try to make it as simple as possible for now.
322+
//
323+
// Events will primarily be ranked by their type in the following order (lower number is higher ranking):
324+
// 1. GENERAL_ASSEMBLY
325+
// 2. COMPANY, ACADEMIC
326+
// 3. SOCIAL, INTERNAL, OTHER, WELCOME
327+
//
328+
// Within each bucket they will be ranked like this:
329+
// 1. Event in future, registration open and not full AND attendance capacity is limited (>0)
330+
// 2. Event in future, registration not started yet (attendance capacity does not matter)
331+
// 3. Event in future, no attendance registration OR attendance capacity is unlimited (=0)
332+
// 4. Event in future, registration full (registration status does not matter)
333+
//
334+
// Past events are not featured. We would rather have no featured events than "stale" events.
335+
const events = await handle.$queryRawTyped(findFeaturedEvents(offset, limit))
336+
337+
// FeaturedEventSchema includes attendance, so we pick just the event shape here
338+
return parseOrReport(FeaturedEventSchema.shape.event.array(), events)
339+
},
340+
313341
async addEventHostingGroups(handle, eventId, hostingGroupIds) {
314342
await handle.eventHostingGroup.createMany({
315343
data: hostingGroupIds

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
EventSchema,
77
EventWithAttendanceSchema,
88
EventWriteSchema,
9+
FeaturedEventSchema,
910
GroupSchema,
1011
UserSchema,
1112
} from "@dotkomonline/types"
@@ -290,6 +291,33 @@ const findManyDeregisterReasonsWithEventProcedure = procedure
290291
}
291292
})
292293

294+
export type FindFeaturedEventsInput = inferProcedureInput<typeof findFeaturedEventsProcedure>
295+
export type FindFeaturedEventsOutput = inferProcedureOutput<typeof findFeaturedEventsProcedure>
296+
const findFeaturedEventsProcedure = procedure
297+
.input(
298+
z.object({
299+
offset: z.number().min(0).default(0),
300+
limit: z.number().min(1).max(100).default(10),
301+
})
302+
)
303+
.output(FeaturedEventSchema.array())
304+
.use(withDatabaseTransaction())
305+
.query(async ({ input, ctx }) => {
306+
const events = await ctx.eventService.findFeaturedEvents(ctx.handle, input.offset, input.limit)
307+
308+
const attendances = await ctx.attendanceService.getAttendancesByIds(
309+
ctx.handle,
310+
events.map((item) => item.attendanceId).filter((id) => id !== null)
311+
)
312+
313+
const eventsWithAttendance = events.map((event) => ({
314+
event,
315+
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) || null,
316+
}))
317+
318+
return eventsWithAttendance
319+
})
320+
293321
export type CreateFileUploadInput = inferProcedureInput<typeof createFileUploadProcedure>
294322
export type CreateFileUploadOutput = inferProcedureOutput<typeof createFileUploadProcedure>
295323
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
@@ -12,6 +12,7 @@ import type {
1212
EventFilterQuery,
1313
EventId,
1414
EventWrite,
15+
FeaturedEvent,
1516
GroupId,
1617
UserId,
1718
} from "@dotkomonline/types"
@@ -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<FeaturedEvent["event"][]>
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

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
"dev": "pnpm -rc --parallel -F @dotkomonline/ui -F @dotkomonline/web -F @dotkomonline/dashboard -F @dotkomonline/rpc -F @dotkomonline/invoicification -F @dotkomonline/rif exec doppler run --preserve-env pnpm run dev",
2222
"dev:web": "pnpm -rc --parallel -F @dotkomonline/ui -F @dotkomonline/web -F @dotkomonline/dashboard -F @dotkomonline/rpc exec doppler run --preserve-env pnpm run dev",
2323
"prisma": "pnpm -F @dotkomonline/db prisma",
24-
"generate": "pnpm -F @dotkomonline/db generate",
25-
"migrate:dev": "pnpm -F @dotkomonline/db migrate",
26-
"migrate:dev-with-fixtures": "pnpm -F @dotkomonline/db prisma migrate dev && pnpm -F @dotkomonline/db apply-fixtures",
27-
"migrate:deploy": "pnpm -F @dotkomonline/db prisma migrate deploy",
28-
"migrate:deploy-with-fixtures": "pnpm -F @dotkomonline/db prisma migrate deploy && pnpm -F @dotkomonline/db apply-fixtures",
24+
"generate": "doppler run --project monoweb-rpc -- pnpm -F @dotkomonline/db generate",
25+
"migrate:dev": "doppler run --project monoweb-rpc -- pnpm -F @dotkomonline/db migrate",
26+
"migrate:dev-with-fixtures": "doppler run --project monoweb-rpc -- pnpm -F @dotkomonline/db prisma migrate dev && pnpm -F @dotkomonline/db apply-fixtures",
27+
"migrate:deploy": "doppler run --project monoweb-rpc -- pnpm -F @dotkomonline/db prisma migrate deploy",
28+
"migrate:deploy-with-fixtures": "doppler run --project monoweb-rpc -- pnpm -F @dotkomonline/db prisma migrate deploy && pnpm -F @dotkomonline/db apply-fixtures",
2929
"migrate:staging": "doppler run --config stg --project monoweb-rpc -- pnpm migrate:deploy",
3030
"migrate:prod": "doppler run --config prd --project monoweb-rpc -- pnpm migrate:deploy",
3131
"storybook": "pnpm run -r --filter=storybook storybook",

packages/db/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@
2323
"type-check": "tsc --noEmit",
2424
"prisma": "prisma",
2525
"migrate": "prisma migrate dev",
26-
"generate": "prisma generate",
27-
"postinstall": "prisma generate",
26+
"generate": "prisma generate --sql",
27+
"postinstall": "prisma generate --sql",
2828
"apply-fixtures": "tsx src/fixtures.ts"
2929
},
3030
"dependencies": {
3131
"@dotkomonline/logger": "workspace:*",
3232
"@dotkomonline/utils": "workspace:*",
33-
"@prisma/client": "^6.8.2",
33+
"@prisma/client": "^6.19.0",
3434
"@testcontainers/postgresql": "^11.5.1",
3535
"date-fns": "^4.1.0",
3636
"pg": "^8.16.0",
@@ -44,7 +44,7 @@
4444
"@types/node": "22.18.6",
4545
"@types/pg": "8.15.6",
4646
"common-tags": "1.8.2",
47-
"prisma": "6.8.2",
47+
"prisma": "6.19.0",
4848
"tsx": "4.20.6",
4949
"typescript": "5.9.3"
5050
}

packages/db/prisma.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from "prisma/config"
2+
3+
if (process.env.DATABASE_URL === undefined) {
4+
throw new Error("Missing database url")
5+
}
6+
7+
export default defineConfig({
8+
schema: "./prisma/schema.prisma",
9+
typedSql: {
10+
path: "./prisma/sql",
11+
},
12+
})

packages/db/prisma/schema.prisma

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
generator client {
22
provider = "prisma-client-js"
3-
previewFeatures = ["relationJoins"]
3+
previewFeatures = ["relationJoins", "typedSql"]
44
}
55

66
generator zod {
@@ -222,14 +222,25 @@ enum EventStatus {
222222
@@map("event_status")
223223
}
224224

225+
// IMPORTANT: The ordering here matters.
226+
// Ascending sort will sort from top to bottom, and it is used in our code for sorting events by significance. Mind
227+
// this when adding new event types, as they are supposed to be ordered in significance. General assembly will always
228+
// be the most significant event type, followed by company events etc.
225229
enum EventType {
226-
SOCIAL @map("SOCIAL")
227-
ACADEMIC @map("ACADEMIC")
228-
COMPANY @map("COMPANY")
229-
// This is called "Generalforsamling" in Norwegian and happens twice a year.
230+
/// Generalforsamling
230231
GENERAL_ASSEMBLY @map("GENERAL_ASSEMBLY")
232+
/// Bedriftspresentasjon
233+
COMPANY @map("COMPANY")
234+
/// Kurs
235+
ACADEMIC @map("ACADEMIC")
236+
SOCIAL @map("SOCIAL")
237+
// These are for the rare occations we have events that are only open to committee members
231238
INTERNAL @map("INTERNAL")
232239
OTHER @map("OTHER")
240+
// These are for a committe called "velkom" and are special social events for new students.
241+
// These have a separate type because we have historically hid these from event lists to not
242+
// spam students that are not new with these events. In older versions of OnlineWeb these
243+
// were even treated as a completely separate event entity.
233244
WELCOME @map("WELCOME")
234245
235246
@@map("event_type")
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 capacity is limited (>0)
16+
-- 2. Event in future, registration not started yet (attendance capacity does not matter)
17+
-- 3. Event in future, no attendance registration OR attendance capacity 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 caps AS (
23+
SELECT
24+
"attendanceId",
25+
SUM("capacity") AS total_capacity
26+
FROM "attendance_pool"
27+
GROUP BY "attendanceId"
28+
),
29+
att_counts AS (
30+
SELECT
31+
"attendanceId",
32+
COUNT(*) AS attendee_count
33+
FROM "attendee"
34+
GROUP BY "attendanceId"
35+
)
36+
SELECT
37+
e.*,
38+
COALESCE(caps.total_capacity, 0) AS "totalCapacity",
39+
COALESCE(att_counts.attendee_count, 0) AS "attendeeCount",
40+
41+
-- 1,2,3: event type buckets
42+
CASE e."type"
43+
WHEN 'GENERAL_ASSEMBLY' THEN 1
44+
WHEN 'COMPANY' THEN 2
45+
WHEN 'ACADEMIC' THEN 2
46+
ELSE 3
47+
END AS "typeRank",
48+
49+
-- 1-4: registration buckets
50+
CASE
51+
-- 1. Future, registration open and not full AND capacity limited (> 0)
52+
WHEN e."attendanceId" IS NOT NULL
53+
AND NOW() BETWEEN a."registerStart" AND a."registerEnd"
54+
AND COALESCE(caps.total_capacity, 0) > 0
55+
AND COALESCE(att_counts.attendee_count, 0) < COALESCE(caps.total_capacity, 0)
56+
THEN 1
57+
58+
-- 2. Future, registration not started yet (capacity doesn't matter)
59+
WHEN e."attendanceId" IS NOT NULL
60+
AND NOW() < a."registerStart"
61+
THEN 2
62+
63+
-- 3. Future, no registration OR unlimited capacity (total capacity = 0)
64+
WHEN e."attendanceId" IS NULL
65+
OR COALESCE(caps.total_capacity, 0) = 0
66+
THEN 3
67+
68+
-- 4. Future, registration full (status doesn't matter)
69+
WHEN e."attendanceId" IS NOT NULL
70+
AND COALESCE(caps.total_capacity, 0) > 0
71+
AND COALESCE(att_counts.attendee_count, 0) >= COALESCE(caps.total_capacity, 0)
72+
THEN 4
73+
74+
-- Fallback: treat as bucket 4
75+
ELSE 4
76+
END AS "registrationBucket"
77+
78+
FROM "event" e
79+
LEFT JOIN "attendance" a
80+
ON a."id" = e."attendanceId"
81+
LEFT JOIN caps
82+
ON caps."attendanceId" = e."attendanceId"
83+
LEFT JOIN att_counts
84+
ON att_counts."attendanceId" = e."attendanceId"
85+
86+
WHERE
87+
e."status" = 'PUBLIC'
88+
-- Past events are not featured
89+
AND e."start" > NOW()
90+
91+
ORDER BY
92+
"typeRank" ASC,
93+
"registrationBucket" ASC,
94+
-- Tie breaker with earlier events first
95+
e."start" ASC
96+
97+
OFFSET $1
98+
LIMIT $2;

0 commit comments

Comments
 (0)