Skip to content

Commit

Permalink
feat(server): validate availability slots
Browse files Browse the repository at this point in the history
  • Loading branch information
neuodev committed Jan 15, 2025
1 parent 425de44 commit e607fc2
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 68 deletions.
28 changes: 23 additions & 5 deletions packages/models/src/availabilitySlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ type SearchFilter = {
* Slot ids to be included in the search query.
*/
slots?: number[];
/**
* Slot ids to be execluded from th search query.
*/
execludeSlots?: number[];
/**
* User ids to be included in the search query.
*/
Expand All @@ -30,7 +34,6 @@ type SearchFilter = {
*/
before?: string;
deleted?: boolean;
pagination?: IFilter.SkippablePagination;
};

export class AvailabilitySlots {
Expand Down Expand Up @@ -99,16 +102,28 @@ export class AvailabilitySlots {
slots,
after,
before,
pagination,
}: WithOptionalTx<SearchFilter>): Promise<Paginated<IAvailabilitySlot.Self>> {
page,
size,
full,
deleted,
execludeSlots,
}: WithOptionalTx<SearchFilter & IFilter.SkippablePagination>): Promise<
Paginated<IAvailabilitySlot.Self>
> {
const baseBuilder = this.applySearchFilter(this.builder(tx), {
users,
slots,
after,
before,
deleted,
execludeSlots,
});
const total = await countRows(baseBuilder.clone());
const rows = await withSkippablePagination(baseBuilder.clone(), pagination);
const rows = await withSkippablePagination(baseBuilder.clone(), {
page,
size,
full,
});
return {
list: rows.map((row) => this.from(row)),
total,
Expand Down Expand Up @@ -153,10 +168,13 @@ export class AvailabilitySlots {

applySearchFilter<R extends object, T>(
builder: Knex.QueryBuilder<R, T>,
{ slots, users, after, before, deleted }: SearchFilter
{ slots, users, after, before, deleted, execludeSlots }: SearchFilter
): Knex.QueryBuilder<R, T> {
if (slots && !isEmpty(slots)) builder.whereIn(this.column("id"), slots);

if (execludeSlots && !isEmpty(execludeSlots))
builder.whereNotIn(this.column("id"), execludeSlots);

if (users && !isEmpty(users))
builder.whereIn(this.column("user_id"), users);

Expand Down
9 changes: 4 additions & 5 deletions packages/models/tests/availabilitySlot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,17 @@ describe("AvailabilitySlots", () => {
it("should NOT delete a list of available AvailabilitySlot rows from the database if it has associated lessons/interviews", async () => {
const user = await fixtures.user({});

const created = await availabilitySlots.create([
const [slot] = await availabilitySlots.create([
{
userId: user.id,
start: dayjs.utc().toISOString(),
end: dayjs.utc().add(1, "hour").toISOString(),
},
]);
await fixtures.lesson({ slot: created[0].id });

const res = await safe(async () =>
availabilitySlots.delete(created.map((slot) => slot.id))
);
await fixtures.lesson({ slot: slot.id });

const res = await safe(async () => availabilitySlots.delete([slot.id]));
expect(res).to.be.instanceof(Error);
});
});
Expand Down
8 changes: 4 additions & 4 deletions packages/sol/src/availabilitySlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ export function subtractSlots(
* checks if a specific slot intersects with at least one slot of the passed list.
* the boundaries are excluded in the intersection.
*/
export function isIntersecting(
target: IAvailabilitySlot.GeneralSlot,
slots: IAvailabilitySlot.GeneralSlot[]
): boolean {
export function isIntersecting<
T extends IAvailabilitySlot.Base,
S extends IAvailabilitySlot.Base,
>(target: T, slots: S[]): boolean {
for (const slot of slots) {
// target slot started after the current slot.
const startedAfterCurrentSlot =
Expand Down
3 changes: 3 additions & 0 deletions packages/sol/tests/availabilitySlot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,19 @@ describe("AvailabilitySlot", () => {
start: now.toISOString(),
end: now.add(1, "hours").toISOString(),
};

const b: IAvailabilitySlot.Slot = {
id: 2,
start: now.add(2, "hours").toISOString(),
end: now.add(3, "hours").toISOString(),
};

const c: IAvailabilitySlot.Slot = {
id: 3,
start: now.add(3, "hours").toISOString(),
end: now.add(4, "hours").toISOString(),
};

const target: IAvailabilitySlot.SubSlot = {
parent: 4,
start: now.add(30, "minutes").toISOString(),
Expand Down
6 changes: 5 additions & 1 deletion packages/types/src/availabilitySlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,18 @@ export type SubSlot = {
end: string;
};

export type Base = {
start: string;
end: string;
};

export type GeneralSlot = Slot | SubSlot;

// API Payloads / Queries
export type FindAvailabilitySlotsApiQuery = {
userId: number;
after?: string;
before?: string;
} & {
pagination?: SkippablePagination;
};

Expand Down
76 changes: 33 additions & 43 deletions services/server/src/handlers/availabilitySlot.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import { bad, conflict, forbidden, notfound } from "@/lib/error";
import { datetime, id, skippablePagination } from "@/validation/utils";
import {
datetime,
id,
jsonBoolean,
pageNumber,
pageSize,
} from "@/validation/utils";
import { isTutor, isTutorManager, isUser } from "@litespace/auth";
import { IAvailabilitySlot } from "@litespace/types";
import { availabilitySlots, knex } from "@litespace/models";
import dayjs from "@/lib/dayjs";
import { NextFunction, Request, Response } from "express";
import safeRequest from "express-async-handler";
import { isIntersecting } from "@litespace/sol";
import zod from "zod";
import { isEmpty } from "lodash";
import { deleteSlots, getSubslots } from "@/lib/availabilitySlot";
import {
deleteSlots,
getSubslots,
isConflictingSlots,
} from "@/lib/availabilitySlot";
import { MAX_FULL_FLAG_DAYS } from "@/constants";

const findPayload = zod.object({
userId: id,
after: datetime,
before: datetime,
pagination: skippablePagination.optional(),
after: datetime.optional(),
before: datetime.optional(),
page: pageNumber.optional(),
size: pageSize.optional(),
full: jsonBoolean.optional(),
});

const setPayload = zod.object({
Expand Down Expand Up @@ -45,7 +56,9 @@ async function find(req: Request, res: Response, next: NextFunction) {
const user = req.user;
if (!isUser(user)) return next(forbidden());

const { userId, after, before, pagination } = findPayload.parse(req.query);
const { userId, after, before, page, size, full } = findPayload.parse(
req.query
);

const diff = dayjs(before).diff(after, "days");
if (diff < 0) return next(bad());
Expand All @@ -55,13 +68,15 @@ async function find(req: Request, res: Response, next: NextFunction) {
before &&
dayjs.utc(before).diff(after, "days") <= MAX_FULL_FLAG_DAYS;

if (pagination?.full && !canUseFullFlag) return next(bad());
if (full && !canUseFullFlag) return next(bad());

const paginatedSlots = await availabilitySlots.find({
users: [userId],
after,
before,
pagination,
page,
size,
full,
});

// NOTE: return only-slots only if the user is a tutor
Expand Down Expand Up @@ -97,13 +112,13 @@ async function set(req: Request, res: Response, next: NextFunction) {
const payload = setPayload.parse(req.body);

const creates = payload.slots.filter(
(obj) => obj.action === "create"
(slot) => slot.action === "create"
) as Array<IAvailabilitySlot.CreateAction>;
const updates = payload.slots.filter(
(obj) => obj.action === "update"
(slot) => slot.action === "update"
) as Array<IAvailabilitySlot.UpdateAction>;
const deletes = payload.slots.filter(
(obj) => obj.action === "delete"
(slot) => slot.action === "delete"
) as Array<IAvailabilitySlot.DeleteAction>;

const error = await knex.transaction(async (tx) => {
Expand All @@ -120,38 +135,13 @@ async function set(req: Request, res: Response, next: NextFunction) {
});
if (!isOwner) return forbidden();

// validations for creates and updates
const mySlots = await availabilitySlots.find({
users: [user.id],
deleted: false,
tx,
const conflicting = await isConflictingSlots({
userId: user.id,
creates,
updates,
deletes,
});

for (const slot of [...creates, ...updates]) {
const start = dayjs.utc(slot.start);
const end = dayjs.utc(slot.end);
// all updates and creates should be in the future
if (start.isBefore(dayjs.utc())) {
return next(bad());
}
// and the end date should be after the start
if (end.isBefore(start) || end.isSame(start)) {
return next(bad());
}
// check for confliction with existing slots
if (!slot.start || !slot.end) continue;

const intersecting = isIntersecting(
{
id: 0,
start: slot.start,
end: slot.end,
},
mySlots.list
);

if (intersecting) return next(conflict());
}
if (conflicting) return conflict();

// delete slots
await deleteSlots({
Expand Down
Loading

0 comments on commit e607fc2

Please sign in to comment.