Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 107 additions & 8 deletions apps/rpc/src/modules/event/event-repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { DBHandle } from "@dotkomonline/db"
import type { DBHandle, Prisma } from "@dotkomonline/db"
import {
type AttendanceId,
type BaseEvent,
BaseEventSchema,
type CompanyId,
type DeregisterReason,
DeregisterReasonSchema,
Expand All @@ -15,11 +17,11 @@ import {
type GroupId,
type UserId,
} from "@dotkomonline/types"
import { getCurrentUTC } from "@dotkomonline/utils"
import type { Prisma } from "@prisma/client"
import { getCurrentUTC, snakeCaseToCamelCase } from "@dotkomonline/utils"
import invariant from "tiny-invariant"
import { parseOrReport } from "../../invariant"
import { type Pageable, pageQuery } from "../../query"
import z from "zod"

const INCLUDE_COMPANY_AND_GROUPS = {
companies: {
Expand All @@ -41,6 +43,8 @@ const INCLUDE_COMPANY_AND_GROUPS = {
export interface EventRepository {
create(handle: DBHandle, data: EventWrite): Promise<Event>
update(handle: DBHandle, eventId: EventId, data: Partial<EventWrite>): Promise<Event>
updateEventAttendance(handle: DBHandle, eventId: EventId, attendanceId: AttendanceId): Promise<Event>
updateEventParent(handle: DBHandle, eventId: EventId, parentEventId: EventId | null): Promise<Event>
/**
* Soft-delete an event by setting its status to "DELETED".
*/
Expand Down Expand Up @@ -81,15 +85,17 @@ export interface EventRepository {
page: Pageable
): Promise<Event[]>
findByParentEventId(handle: DBHandle, parentEventId: EventId): Promise<Event[]>
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>
findManyDeregisterReasonsWithEvent(handle: DBHandle, page: Pageable): Promise<DeregisterReasonWithEvent[]>
// This cannot use `Pageable` due to raw query needing numerical offset and not cursor based pagination
findFeaturedEvents(handle: DBHandle, offset: number, limit: number): Promise<BaseEvent[]>

addEventHostingGroups(handle: DBHandle, eventId: EventId, hostingGroupIds: Set<GroupId>): Promise<void>
addEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
deleteEventHostingGroups(handle: DBHandle, eventId: EventId, hostingGroupIds: Set<GroupId>): Promise<void>
addEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
deleteEventCompanies(handle: DBHandle, eventId: EventId, companyIds: Set<CompanyId>): Promise<void>
updateEventAttendance(handle: DBHandle, eventId: EventId, attendanceId: AttendanceId): Promise<Event>
updateEventParent(handle: DBHandle, eventId: EventId, parentEventId: EventId | null): Promise<Event>
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>

createDeregisterReason(handle: DBHandle, data: DeregisterReasonWrite): Promise<DeregisterReason>
findManyDeregisterReasonsWithEvent(handle: DBHandle, page: Pageable): Promise<DeregisterReasonWithEvent[]>
}

export function getEventRepository(): EventRepository {
Expand Down Expand Up @@ -310,6 +316,99 @@ export function getEventRepository(): EventRepository {
)
},

async findFeaturedEvents(handle, offset, limit) {
/*
Events will primarily be ranked by their type in the following order (lower number is higher ranking):
1. GENERAL_ASSEMBLY
2. COMPANY, ACADEMIC
3. SOCIAL, INTERNAL, OTHER, WELCOME

Within each bucket they will be ranked like this (lower number is higher ranking):
1. Event in future, registration open and not full, AND attendance capacity is limited (>0)
2. Event in future, AND registration not started yet (attendance capacity does not matter)
3. Event in future, AND (no attendance registration OR attendance capacity is unlimited (=0))
4. Event in future, AND registration full (registration status (open/closed etc.) does not matter)

Past events are not featured. We would rather have no featured events than "stale" events.
*/

const events = await handle.$queryRaw`
WITH
capacities AS (
SELECT
attendance_id,
SUM("capacity") AS sum
FROM attendance_pool
GROUP BY attendance_id
),
attendees AS (
SELECT
attendance_id,
COUNT(*) AS count
FROM attendee
GROUP BY attendance_id
)
SELECT
event.*,
COALESCE(capacities.sum, 0) AS total_capacity,
COALESCE(attendees.count, 0) AS attendee_count,
-- 1,2,3: event type buckets
CASE event.type
WHEN 'GENERAL_ASSEMBLY' THEN 1
WHEN 'COMPANY' THEN 2
WHEN 'ACADEMIC' THEN 2
ELSE 3
END AS type_rank,
-- 1-4: registration buckets
CASE
-- 1. Future, registration open and not full AND capacities limited (> 0)
WHEN event.attendance_id IS NOT NULL
AND NOW() BETWEEN attendance.register_start AND attendance.register_end
AND COALESCE(capacities.sum, 0) > 0
AND COALESCE(attendees.count, 0) < COALESCE(capacities.sum, 0)
THEN 1
-- 2. Future, registration not started yet (capacities doesn't matter)
WHEN event.attendance_id IS NOT NULL
AND NOW() < attendance.register_start
THEN 2
-- 3. Future, no registration OR unlimited capacities (total capacities = 0)
WHEN event.attendance_id IS NULL
OR COALESCE(capacities.sum, 0) = 0
THEN 3
-- 4. Future, registration full (status doesn't matter)
WHEN event.attendance_id IS NOT NULL
AND COALESCE(capacities.sum, 0) > 0
AND COALESCE(attendees.count, 0) >= COALESCE(capacities.sum, 0)
THEN 4
-- Fallback: treat as bucket 4
ELSE 4
END AS registration_bucket
FROM event
LEFT JOIN attendance
ON attendance.id = event.attendance_id
LEFT JOIN capacities
ON capacities.attendance_id = event.attendance_id
LEFT JOIN attendees
ON attendees.attendance_id = event.attendance_id
WHERE
event.status = 'PUBLIC'
-- Past events are not featured
AND event.start > NOW()
ORDER BY
type_rank ASC,
registration_bucket ASC,
-- Tiebreaker with earlier events first
event.start ASC
OFFSET ${offset}
LIMIT ${limit};
`

return parseOrReport(
z.preprocess((data) => snakeCaseToCamelCase(data), BaseEventSchema.array()),
events
)
},

async addEventHostingGroups(handle, eventId, hostingGroupIds) {
await handle.eventHostingGroup.createMany({
data: hostingGroupIds
Expand Down
43 changes: 40 additions & 3 deletions apps/rpc/src/modules/event/event-router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { PresignedPost } from "@aws-sdk/s3-presigned-post"
import {
AttendanceSchema,
AttendanceWriteSchema,
BaseEventSchema,
CompanySchema,
EventFilterQuerySchema,
EventSchema,
Expand Down Expand Up @@ -153,7 +155,12 @@ const allEventsProcedure = procedure
export type AllByAttendingUserIdInput = inferProcedureInput<typeof allByAttendingUserIdProcedure>
export type AllByAttendingUserIdOutput = inferProcedureOutput<typeof allByAttendingUserIdProcedure>
const allByAttendingUserIdProcedure = procedure
.input(BasePaginateInputSchema.extend({ filter: EventFilterQuerySchema.optional(), id: UserSchema.shape.id }))
.input(
BasePaginateInputSchema.extend({
filter: EventFilterQuerySchema.optional(),
id: UserSchema.shape.id,
})
)
.output(
z.object({
items: EventWithAttendanceSchema.array(),
Expand All @@ -172,7 +179,7 @@ const allByAttendingUserIdProcedure = procedure

const eventsWithAttendance = events.map((event) => ({
event,
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) || null,
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) ?? null,
}))

return {
Expand Down Expand Up @@ -244,7 +251,7 @@ const findChildEventsProcedure = procedure
)
return events.map((event) => ({
event,
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) || null,
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) ?? null,
}))
})

Expand Down Expand Up @@ -290,6 +297,35 @@ const findManyDeregisterReasonsWithEventProcedure = procedure
}
})

export type FindFeaturedEventsInput = inferProcedureInput<typeof findFeaturedEventsProcedure>
export type FindFeaturedEventsOutput = inferProcedureOutput<typeof findFeaturedEventsProcedure>
const findFeaturedEventsProcedure = procedure
.input(
z
.object({
offset: z.number().min(0).default(0),
limit: z.number().min(1).max(100).default(10),
})
.default({ offset: 0, limit: 1 })
)
.output(z.object({ event: BaseEventSchema, attendance: AttendanceSchema.nullable() }).array())
.use(withDatabaseTransaction())
.query(async ({ input, ctx }) => {
const events = await ctx.eventService.findFeaturedEvents(ctx.handle, input.offset, input.limit)

const attendances = await ctx.attendanceService.getAttendancesByIds(
ctx.handle,
events.map((item) => item.attendanceId).filter((id) => id !== null)
)

const eventsWithAttendance = events.map((event) => ({
event,
attendance: attendances.find((attendance) => attendance.id === event.attendanceId) ?? null,
}))

return eventsWithAttendance
})

export type CreateFileUploadInput = inferProcedureInput<typeof createFileUploadProcedure>
export type CreateFileUploadOutput = inferProcedureOutput<typeof createFileUploadProcedure>
const createFileUploadProcedure = procedure
Expand Down Expand Up @@ -319,4 +355,5 @@ export const eventRouter = t.router({
isOrganizer: isOrganizerProcedure,
findManyDeregisterReasonsWithEvent: findManyDeregisterReasonsWithEventProcedure,
createFileUpload: createFileUploadProcedure,
findFeaturedEvents: findFeaturedEventsProcedure,
})
6 changes: 6 additions & 0 deletions apps/rpc/src/modules/event/event-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { DBHandle } from "@dotkomonline/db"
import { getLogger } from "@dotkomonline/logger"
import type {
AttendanceId,
BaseEvent,
CompanyId,
DeregisterReason,
DeregisterReasonWithEvent,
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface EventService {
findByParentEventId(handle: DBHandle, parentEventId: EventId): Promise<Event[]>
findEventById(handle: DBHandle, eventId: EventId): Promise<Event | null>
findEventsWithUnansweredFeedbackFormByUserId(handle: DBHandle, userId: UserId): Promise<Event[]>
findFeaturedEvents(handle: DBHandle, offset: number, limit: number): Promise<BaseEvent[]>
/**
* Get an event by its id
*
Expand Down Expand Up @@ -93,6 +95,10 @@ export function getEventService(
return await eventRepository.findEventsWithUnansweredFeedbackFormByUserId(handle, userId)
},

async findFeaturedEvents(handle, offset, limit) {
return await eventRepository.findFeaturedEvents(handle, offset, limit)
},

async getEventById(handle, eventId) {
const event = await eventRepository.findById(handle, eventId)
if (!event) {
Expand Down
23 changes: 7 additions & 16 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { EventListItem } from "@/components/molecules/EventListItem/EventListIte
import { OnlineHero } from "@/components/molecules/OnlineHero/OnlineHero"
import { JubileumNotice } from "@/components/notices/jubileum-notice"
import { server } from "@/utils/trpc/server"
import type { Attendance, Event, EventWithAttendance, UserId } from "@dotkomonline/types"
import type { Attendance, BaseEvent, EventWithAttendance, UserId } from "@dotkomonline/types"
import { Button, RichText, Text, Tilt, Title, cn } from "@dotkomonline/ui"
import { createEventPageUrl, getCurrentUTC } from "@dotkomonline/utils"
import { createEventPageUrl } from "@dotkomonline/utils"
import { IconArrowRight, IconCalendarEvent } from "@tabler/icons-react"
import { formatDate } from "date-fns"
import { nb } from "date-fns/locale"
Expand All @@ -15,19 +15,10 @@ import Link from "next/link"
import type { FC } from "react"

export default async function App() {
const [session, isStaff] = await Promise.all([auth.getServerSession(), server.user.isStaff.query()])
const session = await auth.getServerSession()

const { items: events } = await server.event.all.query({
take: 3,
filter: {
byEndDate: {
max: null,
min: getCurrentUTC(),
},
excludingOrganizingGroup: ["velkom"],
excludingType: isStaff ? [] : undefined,
orderBy: "asc",
},
const events = await server.event.findFeaturedEvents.query({
limit: 3,
})

const featuredEvent = events[0] ?? null
Expand Down Expand Up @@ -119,7 +110,7 @@ export default async function App() {
}

interface BigEventCardProps {
event: Event
event: BaseEvent
attendance: Attendance | null
userId: string | null
className?: string
Expand Down Expand Up @@ -174,7 +165,7 @@ const BigEventCard: FC<BigEventCardProps> = ({ event, attendance, userId, classN
}

interface ComingEventProps {
event: Event
event: BaseEvent
attendance: Attendance | null
userId: string | null
className?: string
Expand Down
18 changes: 14 additions & 4 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,23 @@ enum EventStatus {
}

enum EventType {
SOCIAL @map("SOCIAL")
ACADEMIC @map("ACADEMIC")
COMPANY @map("COMPANY")
// This is called "Generalforsamling" in Norwegian and happens twice a year.
/// Generalforsamling
GENERAL_ASSEMBLY @map("GENERAL_ASSEMBLY")
/// Bedriftspresentasjon
COMPANY @map("COMPANY")
/// Kurs
ACADEMIC @map("ACADEMIC")
/// Sosialt
SOCIAL @map("SOCIAL")
// This type is for the rare occation we have an event that is only open to committee members.
/// Komitéarrangement
INTERNAL @map("INTERNAL")
OTHER @map("OTHER")
// This type is for a committe called "velkom" and are special social events for new students.
// These have a separate type because we have historically hid these from event lists to not
// spam students that are not new with these events. In older versions of OnlineWeb these
// were even treated as a completely separate event entity.
/// Velkom/Fadderukene
WELCOME @map("WELCOME")

@@map("event_type")
Expand Down
11 changes: 7 additions & 4 deletions packages/types/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ import { TZDate } from "@date-fns/tz"
* companies, attendance pools, and attendees.
*/

export type EventId = Event["id"]
export type EventType = Event["type"]
export type EventStatus = Event["status"]
export type BaseEvent = z.infer<typeof BaseEventSchema>
export const BaseEventSchema = schemas.EventSchema.extend({})

export type Event = z.infer<typeof EventSchema>
export const EventSchema = schemas.EventSchema.extend({
export const EventSchema = BaseEventSchema.extend({
companies: z.array(CompanySchema),
hostingGroups: z.array(GroupSchema),
})
export type EventId = Event["id"]
export type EventType = Event["type"]
export type EventStatus = Event["status"]

export const EventTypeSchema = schemas.EventTypeSchema
export const EventStatusSchema = schemas.EventStatusSchema
Expand Down
Loading
Loading