Skip to content

Commit

Permalink
add: find and set handlers for availability slots with unit tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
mmoehabb committed Jan 6, 2025
1 parent 4832568 commit d26c6df
Show file tree
Hide file tree
Showing 18 changed files with 671 additions and 50 deletions.
22 changes: 22 additions & 0 deletions packages/atlas/src/availabilitySlot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Base } from "@/base";
import { IAvailabilitySlot } from "@litespace/types";

export class AvailabilitySlot extends Base {
async find(
payload: IAvailabilitySlot.FindAvailabilitySlotsApiParams
): Promise<IAvailabilitySlot.FindAvailabilitySlotsApiResponse> {
return await this.get({
route: `/api/v1/availability-slot/`,
payload,
});
}

async set(
payload: IAvailabilitySlot.SetAvailabilitySlotsApiRequest
): Promise<IAvailabilitySlot.FindAvailabilitySlotsApiResponse> {
return await this.post({
route: `/api/v1/availability-slot/set`,
payload,
});
}
}
14 changes: 9 additions & 5 deletions packages/models/fixtures/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,15 @@ export async function slot(payload?: Partial<IAvailabilitySlot.CreatePayload>) {
const start = dayjs.utc(payload?.start || faker.date.future());
const end = start.add(faker.number.int(8), "hours");

return (await availabilitySlots.create([{
userId: await or.tutorId(payload?.userId),
start: start.toISOString(),
end: end.toISOString(),
}]))[0];
return (
await availabilitySlots.create([
{
userId: await or.tutorId(payload?.userId),
start: start.toISOString(),
end: end.toISOString(),
},
])
)[0];
}

const or = {
Expand Down
12 changes: 10 additions & 2 deletions packages/models/migrations/1716146586880_setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ exports.up = (pgm) => {
duration: { type: "SMALLINT", notNull: true },
price: { type: "INT", notNull: true },
rule_id: { type: "SERIAL", references: "rules(id)", notNull: true },
slot_id: { type: "SERIAL", references: "availability_slots(id)", notNull: true },
slot_id: {
type: "SERIAL",
references: "availability_slots(id)",
notNull: true,
},
session_id: { type: "VARCHAR(50)", notNull: true, primaryKey: true },
canceled_by: { type: "INT", references: "users(id)", default: null },
canceled_at: { type: "TIMESTAMP", default: null },
Expand All @@ -126,7 +130,11 @@ exports.up = (pgm) => {
interviewee_feedback: { type: "TEXT", default: null },
session_id: { type: "TEXT", notNull: true, primaryKey: true },
rule_id: { type: "SERIAL", references: "rules(id)", notNull: true },
slot_id: { type: "SERIAL", references: "availability_slots(id)", notNull: true },
slot_id: {
type: "SERIAL",
references: "availability_slots(id)",
notNull: true,
},
note: { type: "TEXT", default: null },
level: { type: "INT", default: null },
status: { type: "interview_status", default: "pending" },
Expand Down
22 changes: 13 additions & 9 deletions packages/models/scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,13 @@ async function main(): Promise<void> {
});

// seeding slots
await availabilitySlots.create(addedTutors.map(tutor =>
({
await availabilitySlots.create(
addedTutors.map((tutor) => ({
userId: tutor.id,
start: dayjs.utc().startOf("day").toISOString(),
end: dayjs.utc().startOf("day").add(30, "days").toISOString(),
})
));
}))
);

const times = range(0, 24).map((hour) =>
[hour.toString().padStart(2, "0"), "00"].join(":")
Expand Down Expand Up @@ -414,11 +414,15 @@ async function main(): Promise<void> {
);
}

const slot = (await availabilitySlots.create([{
userId: tutorManager.id,
start: dayjs.utc().startOf("day").toISOString(),
end: dayjs.utc().startOf("day").add(1, "days").toISOString(),
}]))[0];
const slot = (
await availabilitySlots.create([
{
userId: tutorManager.id,
start: dayjs.utc().startOf("day").toISOString(),
end: dayjs.utc().startOf("day").add(1, "days").toISOString(),
},
])
)[0];

for (const tutor of addedTutors) {
await knex.transaction(async (tx: Knex.Transaction) => {
Expand Down
70 changes: 55 additions & 15 deletions packages/models/src/availabilitySlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import { IAvailabilitySlot, IFilter, Paginated } from "@litespace/types";
import dayjs from "@/lib/dayjs";
import { Knex } from "knex";
import { first, isEmpty } from "lodash";
import {
import {
knex,
column,
countRows,
WithOptionalTx,
withSkippablePagination,
} from "@/query";
import { lessons } from "@/lessons";
import { interviews } from "@/interviews";

type SearchFilter = {
/**
* Slot ids to be included in the search query.
*/
slots?: number[],
slots?: number[];
/**
* User ids to be included in the search query.
*/
Expand All @@ -29,7 +31,7 @@ type SearchFilter = {
* All slots before (or the same as) this date will be included.
*/
before?: string;
pagination?: IFilter.SkippablePagination,
pagination?: IFilter.SkippablePagination;
};

export class AvailabilitySlots {
Expand Down Expand Up @@ -60,13 +62,33 @@ export class AvailabilitySlots {

async delete(
ids: number[],
tx?: Knex.Transaction
tx: Knex.Transaction
): Promise<IAvailabilitySlot.Self[]> {
if (isEmpty(ids)) throw new Error("At least one id must be passed.");

await lessons
.builder(tx)
.members.join(
lessons.table.lessons,
lessons.columns.lessons("id"),
lessons.columns.members("lesson_id")
)
.whereIn(lessons.columns.lessons("slot_id"), ids)
.del();

await lessons
.builder(tx)
.lessons.whereIn(lessons.columns.lessons("slot_id"), ids)
.del();

await interviews
.builder(tx)
.whereIn(interviews.column("slot_id"), ids)
.del();

const rows = await this.builder(tx)
.whereIn(this.column("id"), ids)
.delete()
.del()
.returning("*");

return rows.map((row) => this.from(row));
Expand Down Expand Up @@ -112,23 +134,41 @@ export class AvailabilitySlots {
total,
};
}

async findById(id: number, tx?: Knex.Transaction): Promise<IAvailabilitySlot.Self | null> {

async findById(
id: number,
tx?: Knex.Transaction
): Promise<IAvailabilitySlot.Self | null> {
const { list } = await this.find({ slots: [id], tx });
return first(list) || null;
}

async isOwner({
slots,
owner,
tx,
}: WithOptionalTx<{
slots: number[];
owner: number;
}>): Promise<boolean> {
const builder = this.builder(tx)
.whereIn(this.column("id"), slots)
.where(this.column("user_id"), owner);
const count = await countRows(builder);
return count === slots.length;
}

async allExist(slots: number[], tx?: Knex.Transaction): Promise<boolean> {
const builder = this.builder(tx).whereIn(this.column("id"), slots);
const count = await countRows(builder.clone());
return Number(count) === slots.length;
}

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

if (users && !isEmpty(users))
builder.whereIn(this.column("user_id"), users);
Expand Down
2 changes: 1 addition & 1 deletion packages/models/src/interviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export class Interviews {
rules?: number[];
slots?: number[];
cancelled?: boolean;
pagination?: IFilter.SkippablePagination,
pagination?: IFilter.SkippablePagination;
} & IFilter.Pagination): Promise<Paginated<IInterview.Self>> {
const baseBuilder = this.builder(tx);

Expand Down
13 changes: 11 additions & 2 deletions packages/models/tests/availabilitySlot.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { availabilitySlots } from "@/availabilitySlots";
import { knex } from "@/query";
import fixtures from "@fixtures/db";
import { dayjs, nameof, safe } from "@litespace/sol";
import { expect } from "chai";
Expand Down Expand Up @@ -129,13 +130,21 @@ describe("AvailabilitySlots", () => {
},
]);

await availabilitySlots.delete(created.map((slot) => slot.id));
await knex.transaction(
async (tx) =>
await availabilitySlots.delete(
created.map((slot) => slot.id),
tx
)
);

const res = await availabilitySlots.find({ users: [user.id] });
expect(res.total).to.eq(0);
});
it("should throw an error if the ids list is empty", async () => {
const res = await safe(async () => availabilitySlots.delete([]));
const res = await safe(async () =>
knex.transaction(async (tx) => await availabilitySlots.delete([], tx))
);
expect(res).to.be.instanceOf(Error);
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export enum ApiError {
EmptyRequest = "empty-request",
UserAlreadyVerified = "user-already-verified",
WrongPassword = "wrong-password",
Conflict = "conflict",
}

export type ApiErrorCode = ApiError | FieldError;
Expand Down
42 changes: 40 additions & 2 deletions packages/types/src/availabilitySlot.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Paginated } from "@/utils";
import { SkippablePagination } from "@/filter";

export type Self = {
id: number;
userId: number;
Expand All @@ -23,8 +26,8 @@ export type CreatePayload = {
};

export type UpdatePayload = {
start: string;
end: string;
start?: string;
end?: string;
};

export type Slot = {
Expand All @@ -40,3 +43,38 @@ export type SubSlot = {
};

export type GeneralSlot = Slot | SubSlot;

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

// API Requests
export type SetAvailabilitySlotsApiRequest = {
slots: Array<
| {
action: "create";
start: string;
end: string;
}
| {
action: "update";
id: number;
start?: string;
end?: string;
}
| {
action: "delete";
id: number;
}
>;
};

// API Responses
export type FindAvailabilitySlotsApiResponse = Paginated<{
slots: Self[];
subslots: SubSlot[];
}>;
22 changes: 13 additions & 9 deletions services/server/fixtures/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,15 @@ export async function slot(payload?: Partial<IAvailabilitySlot.CreatePayload>) {
? dayjs.utc(payload.end)
: start.add(faker.number.int(8), "hours");

return (await availabilitySlots.create([{
userId: payload?.userId || 1,
start: start.toISOString(),
end: end.toISOString(),
}]))[0];
return (
await availabilitySlots.create([
{
userId: payload?.userId || 1,
start: start.toISOString(),
end: end.toISOString(),
},
])
)[0];
}

async function activatedRule(payload?: Partial<IRule.CreatePayload>) {
Expand All @@ -140,8 +144,8 @@ const or = {
async sessionId(type: ISession.Type): Promise<ISession.Id> {
return `${type}:${randomUUID()}`;
},
async ruleId(id?: number): Promise<number> {
if (!id) return await rule().then((rule) => rule.id);
async ruleId(id?: number, userId?: number): Promise<number> {
if (!id) return await rule({ userId }).then((rule) => rule.id);
return id;
},
async slotId(id?: number): Promise<number> {
Expand Down Expand Up @@ -178,7 +182,7 @@ export async function lesson(
: payload?.start || faker.date.soon().toISOString(),
duration: payload?.duration || sample([15, 30]),
price: payload?.price || faker.number.int(500),
rule: await or.ruleId(payload?.rule),
rule: await or.ruleId(payload?.rule, payload?.tutor),
slot: await or.slotId(payload?.slot),
student,
tutor,
Expand All @@ -197,7 +201,7 @@ export async function interview(payload: Partial<IInterview.CreatePayload>) {
interviewer: await or.tutorManagerId(payload.interviewer),
interviewee: await or.tutorId(payload.interviewee),
session: await or.sessionId("interview"),
rule: await or.ruleId(payload.rule),
rule: await or.ruleId(payload.rule, payload.interviewer),
slot: await or.slotId(payload.slot),
start: or.start(payload.start),
});
Expand Down
Loading

0 comments on commit d26c6df

Please sign in to comment.