Skip to content
Open
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
61 changes: 27 additions & 34 deletions bookit/src/services/rule.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { to } from "../utils";
import { Prisma, rule, room } from "@prisma/client";
import { GraphQLError } from "graphql";
import { validRange } from "../utils/date-range";
import { calendarDate, zonedDateTime } from "../utils/time-zone";

type RuleBooking = Omit<InputEvent, "room" | "booked_as"> & { room: room[] };

Expand All @@ -19,28 +20,23 @@ export interface ExplicitRule {
description: string | null;
}

/**
* 6 -> "06"
* 16 -> "16"
*/
const zero = (n: number): string => {
return n >= 10 ? n.toString() : "0" + n;
};
// Rules are expanded per calendar day in the time zone of the rooms, and the
// Date values below hold the instants of those wall-clock times.

export const day = (date: Date): string => {
return `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(date.getDate())}`;
};
/** The calendar date (`YYYY-MM-DD`) of an instant. */
export const day = calendarDate;

export const dayApplies = (date: Date, day_mask: number): boolean => {
const dayIndex = (Math.floor(date.getDay()) + 6) % 7;
/** Whether a calendar date (`YYYY-MM-DD`) is in a day mask (bit 0 is Monday). */
export const dayApplies = (date: string, day_mask: number): boolean => {
const dayIndex = (new Date(`${date}T00:00:00Z`).getUTCDay() + 6) % 7;

return (day_mask >> dayIndex) % 2 > 0;
};

const insertRule = (explicitRules: ExplicitRule[], current: Date, rule: rule): void => {
const insertRule = (explicitRules: ExplicitRule[], date: string, rule: rule): void => {
explicitRules.push({
start: new Date(day(current) + "T" + rule.start_time),
end: new Date(day(current) + "T" + rule.end_time),
start: zonedDateTime(date, rule.start_time),
end: zonedDateTime(date, rule.end_time),
...rule,
});
};
Expand All @@ -63,24 +59,27 @@ export const toExplicitRules = (rules: rule[], from: Date, to: Date): ExplicitRu
}

for (const rule of rules) {
const current = new Date(Math.max(from.getTime(), rule.start_date.getTime()));
const end = new Date(Math.min(to.getTime(), rule.end_date.getTime()));
const first = day(new Date(Math.max(from.getTime(), rule.start_date.getTime())));
const last = day(new Date(Math.min(to.getTime(), rule.end_date.getTime())));

current.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
// Calendar arithmetic happens at UTC midnight, which no TZ setting affects.
const current = new Date(`${first}T00:00:00Z`);
const end = new Date(`${last}T00:00:00Z`);

while (current <= end) {
if (dayApplies(current, rule.day_mask)) {
const date = current.toISOString().slice(0, 10);

if (dayApplies(date, rule.day_mask)) {
if (explicitRules.length >= 10_000) {
throw new GraphQLError("Too many recurring rules. Choose a shorter date range.", {
extensions: { code: "BAD_USER_INPUT" },
});
}

insertRule(explicitRules, current, rule);
insertRule(explicitRules, date, rule);
}

current.setDate(current.getDate() + 1);
current.setUTCDate(current.getUTCDate() + 1);
}
}

Expand Down Expand Up @@ -153,17 +152,11 @@ export const doesObeyRules = (rules: rule[], event: RuleBooking): Error | null =
return null;
};

const stockholmDate = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Stockholm",
year: "numeric",
month: "2-digit",
day: "2-digit",
});

// UTC-midnight rule dates include the entire last day in Stockholm time.
// Rule dates are stored as UTC midnight of their calendar date, so these
// bounds include the entire last day of a rule.
export const ruleDateBounds = (from: Date, to: Date) => ({
end_date: { gte: new Date(`${stockholmDate.format(from)}T00:00:00.000Z`) },
start_date: { lte: new Date(`${stockholmDate.format(to)}T00:00:00.000Z`) },
end_date: { gte: new Date(`${day(from)}T00:00:00.000Z`) },
start_date: { lte: new Date(`${day(to)}T00:00:00.000Z`) },
});

export const getRulesBetween = async (prisma: Prisma.TransactionClient, from: Date, to: Date) => {
Expand Down Expand Up @@ -218,8 +211,8 @@ export const createRule = async (
rule.start_date = day(start);
rule.end_date = day(end);

const start_time = new Date(rule.start_date + "T" + rule.start_time);
const end_time = new Date(rule.start_date + "T" + rule.end_time);
const start_time = zonedDateTime(rule.start_date, rule.start_time);
const end_time = zonedDateTime(rule.start_date, rule.end_time);

if (!validDateTime(start_time, end_time)) {
return {
Expand Down
27 changes: 27 additions & 0 deletions bookit/src/tests/process-time-zone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";

// UTC offsets of 2021-08-19T12:00:00Z, in minutes, as Date reports them.
const zones = { UTC: 0, "America/Chicago": 300, "Pacific/Kiritimati": -840, "Asia/Kolkata": -330 };

/**
* Runs a check once per process time zone. Node re-reads TZ when it is
* assigned, but only in the main thread, so each switch is verified.
*/
export const inEveryProcessTimeZone = (check: (zone: string) => void): void => {
const previous = process.env.TZ;

try {
for (const [zone, offset] of Object.entries(zones)) {
process.env.TZ = zone;

assert.equal(new Date("2021-08-19T12:00:00Z").getTimezoneOffset(), offset, `TZ=${zone}`);
check(zone);
}
} finally {
if (previous === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = previous;
}
}
};
119 changes: 67 additions & 52 deletions bookit/src/tests/rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../services/rule.service";
import type { InputEvent } from "../generated/schema";
import { rule, room } from "@prisma/client";
import { inEveryProcessTimeZone } from "./process-time-zone";

const defaultRule: rule = {
id: "",
Expand Down Expand Up @@ -55,27 +56,25 @@ const dummyRules: rule[] = [
},
];

// Rule times are Stockholm wall-clock times; in August that is UTC+02:00.
describe("Rule utility functions", () => {
it("Should create ISO date", () => {
const date = new Date("2021-08-19");
const time = "17:00";

assert.equal(
new Date("2021-08-19T17:00").toISOString(),
new Date(day(date) + "T" + time).toISOString(),
);
it("uses the Stockholm calendar date of an instant", () => {
assert.equal(day(new Date("2021-08-19T00:00:00Z")), "2021-08-19");
assert.equal(day(new Date("2021-08-18T22:00:00Z")), "2021-08-19");
assert.equal(day(new Date("2021-08-18T21:59:59Z")), "2021-08-18");
assert.equal(day(new Date("2021-01-31T23:30:00Z")), "2021-02-01");
});

it("Should allow day", () => {
assert.equal(dayApplies(new Date("2021-08-19"), 0b0001000), true, "0b0001000");
assert.equal(dayApplies(new Date("2021-08-19"), 0b0001100), true, "0b0001100");
assert.equal(dayApplies(new Date("2021-08-19"), 0b0011000), true, "0b0011000");
assert.equal(dayApplies("2021-08-19", 0b0001000), true, "0b0001000");
assert.equal(dayApplies("2021-08-19", 0b0001100), true, "0b0001100");
assert.equal(dayApplies("2021-08-19", 0b0011000), true, "0b0011000");
});

it("Should not allow day", () => {
assert.equal(dayApplies(new Date("2021-08-19"), 0b1110111), false, "0b1110111");
assert.equal(dayApplies(new Date("2021-08-19"), 0b0000000), false, "0b0000000");
assert.equal(dayApplies(new Date("2021-08-19"), 0b0000111), false, "0b0000111");
assert.equal(dayApplies("2021-08-19", 0b1110111), false, "0b1110111");
assert.equal(dayApplies("2021-08-19", 0b0000000), false, "0b0000000");
assert.equal(dayApplies("2021-08-19", 0b0000111), false, "0b0000111");
});
});

Expand Down Expand Up @@ -108,12 +107,28 @@ describe("Rules to ExplicitRules", () => {
assert.equal(day(result[1].start), "2021-08-20");
});

it("expands rules the same way regardless of the TZ of the process", () => {
const expected = toExplicitRules(dummyRules, new Date("2021-08-16"), new Date("2021-08-22"));

inEveryProcessTimeZone((zone) => {
assert.deepEqual(
toExplicitRules(dummyRules, new Date("2021-08-16"), new Date("2021-08-22")),
expected,
zone,
);
});

assert.equal(expected.length, 5);
assert.equal(expected[0].start.toISOString(), "2021-08-17T05:00:00.000Z");
assert.equal(expected[4].end.toISOString(), "2021-08-20T15:00:00.000Z");
});

it("Should create one mini rule", () => {
const expected: ExplicitRule[] = [
{
...defaultExplicitRules,
start: new Date("2021-08-20T08:00"),
end: new Date("2021-08-20T17:00"),
start: new Date("2021-08-20T08:00+02:00"),
end: new Date("2021-08-20T17:00+02:00"),
},
];

Expand All @@ -131,13 +146,13 @@ describe("Rules to ExplicitRules", () => {
const expected: ExplicitRule[] = [
{
...defaultExplicitRules,
start: new Date("2021-08-19T08:00"),
end: new Date("2021-08-19T17:00"),
start: new Date("2021-08-19T08:00+02:00"),
end: new Date("2021-08-19T17:00+02:00"),
},
{
...defaultExplicitRules,
start: new Date("2021-08-20T08:00"),
end: new Date("2021-08-20T17:00"),
start: new Date("2021-08-20T08:00+02:00"),
end: new Date("2021-08-20T17:00+02:00"),
},
];

Expand All @@ -156,13 +171,13 @@ describe("Rules to ExplicitRules", () => {
const expected: ExplicitRule[] = [
{
...dummyRules[1],
start: new Date("2021-08-17T07:00"),
end: new Date("2021-08-17T10:00"),
start: new Date("2021-08-17T07:00+02:00"),
end: new Date("2021-08-17T10:00+02:00"),
},
{
...dummyRules[0],
start: new Date("2021-08-18T08:00"),
end: new Date("2021-08-18T17:00"),
start: new Date("2021-08-18T08:00+02:00"),
end: new Date("2021-08-18T17:00+02:00"),
},
];

Expand Down Expand Up @@ -201,8 +216,8 @@ describe("Booking rule scope", () => {
it("does not let permission in one room override restrictions in another", () => {
const booking: Omit<InputEvent, "room"> & { room: room[] } = {
title: "Two rooms",
start: "2021-08-20T11:00",
end: "2021-08-20T12:00",
start: "2021-08-20T09:00:00.000Z",
end: "2021-08-20T10:00:00.000Z",
room: ["BIG_HUB", "GROUP_ROOM"],
phone: "0701234567",
booked_as: "digit",
Expand Down Expand Up @@ -246,22 +261,22 @@ describe("Merge rules", () => {
it("preserves later restrictions when an inserted interval is already covered", () => {
const first = {
...defaultExplicitRules,
start: new Date("2021-08-20T08:00"),
end: new Date("2021-08-20T10:00"),
start: new Date("2021-08-20T08:00+02:00"),
end: new Date("2021-08-20T10:00+02:00"),
priority: 1,
};

const later = {
...defaultExplicitRules,
start: new Date("2021-08-20T14:00"),
end: new Date("2021-08-20T16:00"),
start: new Date("2021-08-20T14:00+02:00"),
end: new Date("2021-08-20T16:00+02:00"),
priority: 1,
};

const covered = {
...defaultExplicitRules,
start: new Date("2021-08-20T08:00"),
end: new Date("2021-08-20T09:00"),
start: new Date("2021-08-20T08:00+02:00"),
end: new Date("2021-08-20T09:00+02:00"),
priority: 2,
allow: true,
};
Expand All @@ -280,8 +295,8 @@ describe("Merge rules", () => {
const expected: ExplicitRule[] = [
{
...defaultExplicitRules,
start: new Date("2021-08-20T08:00"),
end: new Date("2021-08-20T17:00"),
start: new Date("2021-08-20T08:00+02:00"),
end: new Date("2021-08-20T17:00+02:00"),
},
];

Expand Down Expand Up @@ -322,13 +337,13 @@ describe("Merge rules", () => {
const expected: ExplicitRule[] = [
{
...rules[1],
start: new Date("2021-08-20T07:00"),
end: new Date("2021-08-20T10:00"),
start: new Date("2021-08-20T07:00+02:00"),
end: new Date("2021-08-20T10:00+02:00"),
},
{
...rules[0],
start: new Date("2021-08-20T10:00"),
end: new Date("2021-08-20T17:00"),
start: new Date("2021-08-20T10:00+02:00"),
end: new Date("2021-08-20T17:00+02:00"),
},
];

Expand Down Expand Up @@ -368,18 +383,18 @@ describe("Merge rules", () => {
const expected: ExplicitRule[] = [
{
...rules[0],
start: new Date("2021-08-20T08:00"),
end: new Date("2021-08-20T10:00"),
start: new Date("2021-08-20T08:00+02:00"),
end: new Date("2021-08-20T10:00+02:00"),
},
{
...rules[1],
start: new Date("2021-08-20T10:00"),
end: new Date("2021-08-20T13:00"),
start: new Date("2021-08-20T10:00+02:00"),
end: new Date("2021-08-20T13:00+02:00"),
},
{
...rules[0],
start: new Date("2021-08-20T13:00"),
end: new Date("2021-08-20T17:00"),
start: new Date("2021-08-20T13:00+02:00"),
end: new Date("2021-08-20T17:00+02:00"),
},
];

Expand Down Expand Up @@ -430,23 +445,23 @@ describe("Merge rules", () => {
const expected: ExplicitRule[] = [
{
...rules[0],
start: new Date("2021-08-20T08:00"),
end: new Date("2021-08-20T10:00"),
start: new Date("2021-08-20T08:00+02:00"),
end: new Date("2021-08-20T10:00+02:00"),
},
{
...rules[1],
start: new Date("2021-08-20T10:00"),
end: new Date("2021-08-20T13:00"),
start: new Date("2021-08-20T10:00+02:00"),
end: new Date("2021-08-20T13:00+02:00"),
},
{
...rules[0],
start: new Date("2021-08-20T13:00"),
end: new Date("2021-08-20T17:00"),
start: new Date("2021-08-20T13:00+02:00"),
end: new Date("2021-08-20T17:00+02:00"),
},
{
...rules[2],
start: new Date("2021-08-20T17:00"),
end: new Date("2021-08-20T19:00"),
start: new Date("2021-08-20T17:00+02:00"),
end: new Date("2021-08-20T19:00+02:00"),
},
];

Expand Down
Loading
Loading