feat(events): add weekly browsing and personal registrations
This commit is contained in:
1 parent
25d0a13050
commit
96d3e3f602
7 files changed
+655
-74
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) ?? "",
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user