Skip to content

feat: Allow user scope to view old events #153

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 21, 2024
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
10 changes: 5 additions & 5 deletions packages/ilmomasiina-backend/src/models/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,16 @@ export default function setupEventModel(sequelize: Sequelize) {
draft: false,
// and either:
[Op.or]: {
// closed less than a week ago
// closed less than 6 months ago
registrationEndDate: {
[Op.gt]: moment().subtract(7, "days").toDate(),
[Op.gt]: moment().subtract(6, "months").toDate(),
},
// or happened less than a week ago
// or happened less than 6 months ago
date: {
[Op.gt]: moment().subtract(7, "days").toDate(),
[Op.gt]: moment().subtract(6, "months").toDate(),
},
endDate: {
[Op.gt]: moment().subtract(7, "days").toDate(),
[Op.gt]: moment().subtract(6, "months").toDate(),
},
},
},
Expand Down
60 changes: 38 additions & 22 deletions packages/ilmomasiina-backend/src/routes/events/getEventDetails.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { NotFound } from "http-errors";
import moment from "moment";
import { Op } from "sequelize";

import type {
Expand Down Expand Up @@ -32,7 +33,11 @@ export const basicEventInfoCached = createCache({
async get(eventSlug: EventSlug) {
// First query general event information
const event = await Event.scope("user").findOne({
where: { slug: eventSlug },
where: {
slug: eventSlug,
// are not drafts,
draft: false,
},
attributes: eventGetEventAttrs,
include: [
{
Expand Down Expand Up @@ -67,33 +72,44 @@ export const eventDetailsForUserCached = createCache({
async get(eventSlug: EventSlug) {
const { event, publicQuestions } = await basicEventInfoCached(eventSlug);

// If registrationEndDate or endDate or date is more than a week ago, return nothing
const isOld =
event.registrationEndDate &&
moment(event.registrationEndDate).isBefore(moment().subtract(7, "days")) &&
event.date &&
moment(event.date).isBefore(moment().subtract(7, "days")) &&
event.endDate &&
moment(event.endDate).isBefore(moment().subtract(7, "days"));

// Query all quotas for the event
const quotas = await Quota.findAll({
where: { eventId: event.id },
attributes: eventGetQuotaAttrs,
include: [
// Include all signups for the quota
{
model: Signup.scope("active"),
attributes: eventGetSignupAttrs,
required: false,
const quotas = isOld
? []
: await Quota.findAll({
where: { eventId: event.id },
attributes: eventGetQuotaAttrs,
include: [
// ... and public answers of signups
// Include all signups for the quota
{
model: Answer,
attributes: eventGetAnswerAttrs,
model: Signup.scope("active"),
attributes: eventGetSignupAttrs,
required: false,
where: { questionId: { [Op.in]: publicQuestions } },
include: [
// ... and public answers of signups
{
model: Answer,
attributes: eventGetAnswerAttrs,
required: false,
where: { questionId: { [Op.in]: publicQuestions } },
},
],
},
],
},
],
// First sort by Quota order, then by signup creation date
order: [
["order", "ASC"],
[Signup, "createdAt", "ASC"],
],
});
// First sort by Quota order, then by signup creation date
order: [
["order", "ASC"],
[Signup, "createdAt", "ASC"],
],
});

return {
event: {
Expand Down
37 changes: 33 additions & 4 deletions packages/ilmomasiina-backend/src/routes/events/getEventsList.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { col, fn, Order } from "sequelize";
import moment from "moment";
import { col, fn, Op, Order, WhereOptions } from "sequelize";

import type { AdminEventListResponse, EventListQuery, UserEventListResponse } from "@tietokilta/ilmomasiina-models";
import { adminEventListEventAttrs, eventListEventAttrs } from "@tietokilta/ilmomasiina-models/dist/attrs/event";
Expand All @@ -24,8 +25,36 @@ function eventOrder(): Order {
export const eventsListForUserCached = createCache({
maxAgeMs: 1000,
maxPendingAgeMs: 2000,
async get(category?: string) {
const where = category ? { category } : {};
async get(options: { category?: string; since?: string }) {
const { category, since } = options;
// Default to 7 days ago
const sinceDate = since ? new Date(since) : undefined;
const filters: WhereOptions = {};
if (category) {
filters.category = category;
}
if (since && !Number.isNaN(sinceDate)) {
filters.endDate = {
[Op.gte]: sinceDate,
};
} else {
filters[Op.or as any] = {
// closed less than 7 days ago
registrationEndDate: {
[Op.gt]: moment().subtract(7, "days").toDate(),
},
// or happened less than 7 days ago
date: {
[Op.gt]: moment().subtract(7, "days").toDate(),
},
endDate: {
[Op.gt]: moment().subtract(7, "days").toDate(),
},
};
}
const where = {
...filters,
};

const events = await Event.scope("user").findAll({
attributes: eventListEventAttrs,
Expand Down Expand Up @@ -68,7 +97,7 @@ export async function getEventsListForUser(
throw new InitialSetupNeeded("Initial setup of Ilmomasiina is needed.");
}

const res = await eventsListForUserCached(request.query.category);
const res = await eventsListForUserCached({ category: request.query.category, since: request.query.since });
reply.status(200);
return res as StringifyApi<typeof res>;
}
Expand Down
34 changes: 29 additions & 5 deletions packages/ilmomasiina-backend/src/util/cache.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import crypto from "crypto";

interface Options<A, R> {
/** Maximum number of milliseconds since start of call that a result can be reused. */
maxAgeMs: number;
Expand All @@ -22,6 +24,27 @@ interface CachedGet<A, R> {
invalidate(key?: A): void;
}

function hashObject(object: any): string {
const hash = crypto.createHash("sha256");

function processObject(obj: any): void {
if (obj === null || obj === undefined) {
hash.update(String(obj));
} else if (typeof obj === "object") {
const keys = Object.keys(obj).sort();
for (const key of keys) {
hash.update(key);
processObject(obj[key]);
}
} else {
hash.update(String(obj));
}
}

processObject(object);
return hash.digest("hex");
}

/**
* Wraps the `get` function in a cache.
*
Expand All @@ -44,10 +67,11 @@ export default function createCache<A, R>({
return dummyGet;
}

const cache = new Map<A, Ongoing<R>>();
const cache = new Map<string, Ongoing<R>>();

const cachedGet = (async (key: A) => {
const currentGet = cache.get(key);
const hashedKey = hashObject(key);
const currentGet = cache.get(hashedKey);

// Reuse successful and pending queries as described above.
if (
Expand All @@ -73,8 +97,8 @@ export default function createCache<A, R>({
state: "running",
};
// Delete, then set, to ensure the key is bumped to the end.
cache.delete(key);
cache.set(key, newGet);
cache.delete(hashedKey);
cache.set(hashedKey, newGet);

// Delete least-recently-used entries.
if (cache.size > maxSize) {
Expand All @@ -86,7 +110,7 @@ export default function createCache<A, R>({
}) as CachedGet<A, R>;

cachedGet.invalidate = (key) => {
if (key) cache.delete(key);
if (key) cache.delete(hashObject(key));
else cache.clear();
};

Expand Down
20 changes: 18 additions & 2 deletions packages/ilmomasiina-backend/test/routes/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { UserEventListResponse, UserEventResponse } from "@tietokilta/ilmomasiin
import { Event } from "../../src/models/event";
import { fetchSignups, testEvent, testSignups } from "../testData";

async function fetchUserEventList() {
const response = await server.inject({ method: "GET", url: "/api/events" });
async function fetchUserEventList(query?: { since?: string }) {
const response = await server.inject({ method: "GET", url: "/api/events", query });
return [response.json<UserEventListResponse>(), response] as const;
}

Expand Down Expand Up @@ -296,6 +296,22 @@ describe("getEventList", () => {
expect(data).toEqual([]);
});

test("returns events since specified date", async () => {
const since = new Date();
await testEvent({ inPast: true });
const [data] = await fetchUserEventList({ since: since.toISOString() });
expect(data).toEqual([]);

await testEvent({ hasDate: true });
const [data2] = await fetchUserEventList({ since: since.toISOString() });
expect(data2).toHaveLength(1);

// 30 days in the future
const sinceFuture = new Date(since.getTime() + 2592000000);
const [data3] = await fetchUserEventList({ since: sinceFuture.toISOString() });
expect(data3).toEqual([]);
});

test("returns quotas in correct order", async () => {
const event = await testEvent({ quotaCount: 3 });
const [before] = await fetchUserEventList();
Expand Down
4 changes: 2 additions & 2 deletions packages/ilmomasiina-backend/test/testData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export async function testEvent(
if (hasDate) {
if (inPast) {
event.endDate = faker.date.recent({
refDate: moment().subtract(14, "days").toDate(),
refDate: moment().subtract(8, "months").toDate(),
});
event.date = faker.date.recent({ refDate: event.endDate });
} else {
Expand All @@ -75,7 +75,7 @@ export async function testEvent(
if (hasSignup) {
if (inPast && signupState === "closed") {
event.registrationEndDate = faker.date.recent({
refDate: moment().subtract(14, "days").toDate(),
refDate: moment().subtract(8, "months").toDate(),
});
event.registrationStartDate = faker.date.recent({
refDate: event.registrationEndDate,
Expand Down
6 changes: 6 additions & 0 deletions packages/ilmomasiina-models/src/schema/eventList/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export const eventListQuery = Type.Object({
description: "If set, only events with the provided category are included.",
}),
),
since: Type.Optional(
Type.String({
description: "If set, only events starting after this date are included.",
format: "date-time",
}),
),
});

/** Query parameters applicable to the public event list API. */
Expand Down
Loading