feat(events): add weekly browsing and personal registrations
CI / check (push) Failing after 53s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 00:27:41 +02:00
1 parent 25d0a13050
commit 96d3e3f602
7 files changed
+655 -74

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
total: 60,
fail: false,
queries: [] as { sql: string; params: unknown[] }[],
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
return {
...schema,
db: drizzle(async (sql, params) => {
if (state.fail) throw Error("unavailable");
state.queries.push({ sql, params });
return { rows: sql.includes("count(*)") ? [[state.total]] : [] };
}),
};
});
import { loadPublicEvents } from "./public-events";
describe("public events", () => {
beforeEach(() => {
state.queries = [];
state.fail = false;
state.total = 60;
});
it("limits public upcoming events before pagination", async () => {
expect(
await loadPublicEvents({
status: "upcoming",
page: 2,
now: new Date("2026-09-11"),
}),
).toMatchObject({ page: 2, lastPage: 3 });
expect(state.queries[1].params.slice(-2)).toEqual([24, 24]);
expect(state.queries[0].params.slice(0, 3)).toEqual([
"published",
"completed",
"published",
]);
expect(state.queries[0].sql).toContain("`starts_at` > ?");
});
it("filters ongoing events using both start and end", async () => {
await loadPublicEvents({ status: "ongoing" });
expect(state.queries[0].sql).toContain("`starts_at` <= ?");
expect(state.queries[0].sql).toContain("`ends_at` > ?");
});
it("filters registrations by the authenticated user and weekly bounds", async () => {
await loadPublicEvents({ mine: true, userId: 77, week: "2026-09-07" });
expect(state.queries[0].sql).toContain("exists (select");
expect(state.queries[0].params).toContain(77);
expect(state.queries[0].sql).toContain("`starts_at` >= ?");
expect(state.queries[0].sql).toContain("`starts_at` < ?");
});
it("returns no personal events for signed out visitors", async () => {
await loadPublicEvents({ mine: true });
expect(state.queries[0].sql).toContain("1 = 0");
});
it("does not mask service failures as an empty event list", async () => {
state.fail = true;
await expect(loadPublicEvents()).rejects.toThrow();
});
});
+176
View File
@@ -0,0 +1,176 @@
import "server-only";
import {
and,
asc,
count,
desc,
eq,
exists,
gt,
gte,
inArray,
isNull,
lt,
lte,
or,
sql,
} from "drizzle-orm";
import {
db,
WebsiteEvent,
WebsiteEventRegistration,
WebsiteEventType,
} from "@/lib/db";
export async function loadPublicEvents(
options: {
status?: string;
week?: string;
page?: number;
mine?: boolean;
userId?: number;
now?: Date;
} = {},
) {
const now = options.now ?? new Date();
const status = ["upcoming", "ongoing", "completed"].includes(
options.status ?? "",
)
? options.status
: "all";
const weekDate = /^\d{4}-\d{2}-\d{2}$/.test(options.week ?? "")
? new Date(`${options.week}T00:00:00Z`)
: null;
const week =
weekDate &&
Number.isFinite(weekDate.getTime()) &&
weekDate.toISOString().slice(0, 10) === options.week
? weekDate
: null;
const weekEnd = week ? new Date(week.getTime() + 7 * 86400000) : null;
const completed = or(
eq(WebsiteEvent.status, "completed"),
lte(WebsiteEvent.endsAt, now),
);
const where = and(
inArray(WebsiteEvent.status, ["published", "completed"]),
status === "completed"
? completed
: status === "upcoming"
? and(
eq(WebsiteEvent.status, "published"),
gt(WebsiteEvent.startsAt, now),
or(isNull(WebsiteEvent.endsAt), gt(WebsiteEvent.endsAt, now)),
)
: status === "ongoing"
? and(
eq(WebsiteEvent.status, "published"),
lte(WebsiteEvent.startsAt, now),
or(isNull(WebsiteEvent.endsAt), gt(WebsiteEvent.endsAt, now)),
)
: undefined,
week && weekEnd
? and(
gte(WebsiteEvent.startsAt, week),
lt(WebsiteEvent.startsAt, weekEnd),
)
: undefined,
options.mine
? options.userId
? exists(
db
.select({ id: WebsiteEventRegistration.id })
.from(WebsiteEventRegistration)
.where(
and(
eq(WebsiteEventRegistration.eventId, WebsiteEvent.id),
eq(WebsiteEventRegistration.userId, options.userId),
),
),
)
: sql`1 = 0`
: undefined,
);
const totals = await db
.select({ value: count() })
.from(WebsiteEvent)
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
.where(where);
const total = Number(totals[0]?.value ?? 0);
const lastPage = Math.max(1, Math.ceil(total / 24));
const page = Math.min(
lastPage,
Number.isFinite(options.page)
? Math.max(1, Math.trunc(options.page ?? 1))
: 1,
);
const rows = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
description: WebsiteEvent.description,
status: WebsiteEvent.status,
image: WebsiteEvent.image,
startsAt: WebsiteEvent.startsAt,
endsAt: WebsiteEvent.endsAt,
maxPlayers: WebsiteEvent.maxPlayers,
typeName: WebsiteEventType.name,
typeColor: WebsiteEventType.color,
typeSlug: WebsiteEventType.slug,
})
.from(WebsiteEvent)
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
.where(where)
.orderBy(
status === "completed"
? desc(WebsiteEvent.startsAt)
: asc(WebsiteEvent.startsAt),
asc(WebsiteEvent.id),
)
.limit(24)
.offset((page - 1) * 24);
const ids = rows.map((row) => row.id);
const counts = ids.length
? await db
.select({ eventId: WebsiteEventRegistration.eventId, value: count() })
.from(WebsiteEventRegistration)
.where(inArray(WebsiteEventRegistration.eventId, ids))
.groupBy(WebsiteEventRegistration.eventId)
: [];
const own =
ids.length && options.userId
? await db
.select({ eventId: WebsiteEventRegistration.eventId })
.from(WebsiteEventRegistration)
.where(
and(
inArray(WebsiteEventRegistration.eventId, ids),
eq(WebsiteEventRegistration.userId, options.userId),
),
)
: [];
const events = rows.map((row) => ({
...row,
phase:
row.status === "completed" || (row.endsAt && row.endsAt <= now)
? "completed"
: row.startsAt > now
? "upcoming"
: "ongoing",
registered: own.some((r) => r.eventId === row.id),
type: { name: row.typeName, color: row.typeColor, slug: row.typeSlug },
_count: {
registrations: Number(
counts.find((r) => r.eventId === row.id)?.value ?? 0,
),
},
}));
return {
events,
total,
page,
lastPage,
status,
week: week?.toISOString().slice(0, 10) ?? "",
};
}