Skip to content

Commit

Permalink
Merge pull request #251 from litespace-org/moehab/availability-slot
Browse files Browse the repository at this point in the history
added: AvailabilitySlot table/model basic functionality and test units.
  • Loading branch information
neuodev authored Dec 26, 2024
2 parents 7347f89 + b737a3e commit 6684773
Show file tree
Hide file tree
Showing 9 changed files with 333 additions and 79 deletions.
2 changes: 2 additions & 0 deletions packages/models/fixtures/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import { entries, range, sample } from "lodash";
import { Knex } from "knex";
import dayjs from "@/lib/dayjs";
import { Time } from "@litespace/sol/time";
import { availabilitySlots } from "@/availabilitySlot";

export async function flush() {
await knex.transaction(async (tx) => {
await availabilitySlots.builder(tx).del();
await topics.builder(tx).userTopics.del();
await topics.builder(tx).topics.del();
await messages.builder(tx).del();
Expand Down
12 changes: 12 additions & 0 deletions packages/models/migrations/1716146586880_setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ exports.up = (pgm) => {
updated_at: { type: "TIMESTAMP", notNull: true },
});

pgm.createTable("availability_slots", {
id: { type: "SERIAL", primaryKey: true, notNull: true },
user_id: { type: "INT", notNull: true, references: "users(id)" },
start: { type: "TIMESTAMP", notNull: true },
end: { type: "TIMESTAMP", notNull: true },
created_at: { type: "TIMESTAMP", notNull: true },
updated_at: { type: "TIMESTAMP", notNull: true },
});

pgm.createTable("calls", {
id: { type: "SERIAL", primaryKey: true, unique: true, notNull: true },
recording_status: {
Expand Down Expand Up @@ -357,6 +366,7 @@ exports.up = (pgm) => {
pgm.createIndex("calls", "id");
pgm.createIndex("lessons", "id");
pgm.createIndex("rules", "id");
pgm.createIndex("availability_slots", "id");
pgm.createIndex("tutors", "id");
pgm.createIndex("users", "id");
pgm.createIndex("ratings", "id");
Expand All @@ -379,6 +389,7 @@ exports.up = (pgm) => {
*/
exports.down = (pgm) => {
// indexes
pgm.dropIndex("availability_slots", "id", { ifExists: true });
pgm.dropIndex("invoices", "id", { ifExists: true });
pgm.dropIndex("messages", "id", { ifExists: true });
pgm.dropIndex("rooms", "id", { ifExists: true });
Expand All @@ -397,6 +408,7 @@ exports.down = (pgm) => {
pgm.dropIndex("users", "id", { ifExists: true });

// tables
pgm.dropTable("availability_slots", { ifExists: true });
pgm.dropTable("user_topics", { ifExists: true });
pgm.dropTable("topics", { ifExists: true });
pgm.dropTable("withdraw_methods", { ifExists: true });
Expand Down
135 changes: 135 additions & 0 deletions packages/models/src/availabilitySlot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { IAvailabilitySlot } from "@litespace/types";
import dayjs from "@/lib/dayjs";
import { Knex } from "knex";
import { first, isEmpty } from "lodash";
import { knex, column, WithOptionalTx } from "@/query";

type SearchFilter = {
/**
* User ids to be included in the search query.
*/
users?: number[];
/**
* Start date time (ISO datetime format)
* All slots after (or the same as) this date will be included.
*/
after?: string;
/**
* End date time (ISO datetime format)
* All slots before (or the same as) this date will be included.
*/
before?: string;
};

export class AvailabilitySlots {
table = "availability_slots" as const;

async create(
payloads: IAvailabilitySlot.CreatePayload[],
tx?: Knex.Transaction
): Promise<IAvailabilitySlot.Self[]> {
if (isEmpty(payloads)) throw new Error("At least one payload must be passed.");

const now = dayjs.utc().toDate();
const rows = await this.builder(tx)
.insert(payloads.map(payload => ({
user_id: payload.userId,
start: dayjs.utc(payload.start).toDate(),
end: dayjs.utc(payload.end).toDate(),
created_at: now,
updated_at: now,
})))
.returning("*");

return rows.map(row => this.from(row));
}

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

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

return rows.map(row => this.from(row));
}

async update(
id: number,
payload: IAvailabilitySlot.UpdatePayload,
tx?: Knex.Transaction
): Promise<IAvailabilitySlot.Self> {
const rows = await this.builder(tx)
.update({
start: payload.start ? dayjs.utc(payload.start).toDate() : undefined,
end: payload.end ? dayjs.utc(payload.end).toDate() : undefined,
updated_at: dayjs.utc().toDate(),
})
.where("id", id)
.returning("*");

const row = first(rows);
if (!row) throw new Error("Slot not found; should never happen.");
return this.from(row);
}

async find({
tx,
users,
after,
before,
}: WithOptionalTx<SearchFilter>
): Promise<IAvailabilitySlot.Self[]> {
const baseBuilder = this.applySearchFilter(this.builder(tx), {
users,
after,
before,
});
const rows = await baseBuilder.clone().select();
return rows.map(row => this.from(row));
}

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

if (after)
builder.where(this.column("start"), ">=", dayjs.utc(after).toDate());
if (before)
builder.where(this.column("end"), "<=", dayjs.utc(before).toDate());

return builder;
}

from(row: IAvailabilitySlot.Row): IAvailabilitySlot.Self {
return {
id: row.id,
userId: row.user_id,
start: row.start.toISOString(),
end: row.end.toISOString(),
createAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}

builder(tx?: Knex.Transaction) {
return tx ? tx<IAvailabilitySlot.Row>(this.table) : knex<IAvailabilitySlot.Row>(this.table);
}

column(value: keyof IAvailabilitySlot.Row) {
return column(value, this.table);
}
}

export const availabilitySlots = new AvailabilitySlots();
142 changes: 142 additions & 0 deletions packages/models/tests/availabilitySlot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { availabilitySlots } from "@/availabilitySlot";
import fixtures from "@fixtures/db";
import { dayjs, nameof, safe } from "@litespace/sol";
import { expect } from "chai";
import { first } from "lodash";

describe("AvailabilitySlots", () => {
beforeEach(async () => {
return await fixtures.flush();
});

describe(nameof(availabilitySlots.create), () => {
it("should create a list of new AvailabilitySlot rows in the database", async () => {
const user = await fixtures.user({});
const res = await availabilitySlots.create([
{
userId: user.id,
start: dayjs.utc().toISOString(),
end: dayjs.utc().add(1, "hour").toISOString()
},
{
userId: user.id,
start: dayjs.utc().add(2, "hour").toISOString(),
end: dayjs.utc().add(4, "hour").toISOString()
},
]);
expect(res).to.have.length(2);
});
it("should throw an error if the payloads list is empty", async () => {
const res = await safe(async () => availabilitySlots.create([]));
expect(res).to.be.instanceOf(Error);
});
});

describe(nameof(availabilitySlots.find), () => {
it("should retieve AvailabilitySlot rows of a list of users", async () => {
const user1 = await fixtures.user({});
const user2 = await fixtures.user({});

const slots = await availabilitySlots.create([
{
userId: user1.id,
start: dayjs.utc().toISOString(),
end: dayjs.utc().add(1, "hour").toISOString()
},
{
userId: user1.id,
start: dayjs.utc().add(2, "hour").toISOString(),
end: dayjs.utc().add(4, "hour").toISOString()
},
{
userId: user2.id,
start: dayjs.utc().add(6, "hour").toISOString(),
end: dayjs.utc().add(7, "hour").toISOString()
},
]);

const res = await availabilitySlots.find({ users: [user1.id, user2.id] })
expect(res).to.have.length(3);
expect(res).to.deep.eq(slots);
});
it("should retieve AvailabilitySlot rows between two dates", async () => {
const user = await fixtures.user({});

const slots = await availabilitySlots.create([
{
userId: user.id,
start: dayjs.utc().toISOString(),
end: dayjs.utc().add(1, "hour").toISOString()
},
{
userId: user.id,
start: dayjs.utc().add(2, "hour").toISOString(),
end: dayjs.utc().add(4, "hour").toISOString()
},
{
userId: user.id,
start: dayjs.utc().add(6, "hour").toISOString(),
end: dayjs.utc().add(7, "hour").toISOString()
},
]);

const res = await availabilitySlots.find({
after: slots[0].start,
before: slots[1].end
});

expect(res).to.have.length(2);
expect(res).to.deep.eq(slots.slice(0,2));
});
});

describe(nameof(availabilitySlots.update), () => {
it("should update an available AvailabilitySlot row in the database", async () => {
const user = await fixtures.user({});

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

const updated = await availabilitySlots.update(slots[0].id, {
start: dayjs.utc().add(2, "hour").toISOString(),
end: dayjs.utc().add(3, "hour").toISOString()
});

const found = first(await availabilitySlots.find({ users: [user.id] }));
expect(found).to.deep.eq(updated);
});
});

describe(nameof(availabilitySlots.delete), () => {
it("should delete a list of available AvailabilitySlot rows from the database", async () => {
const user = await fixtures.user({});

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

await availabilitySlots.delete(created.map(slot => slot.id));

const res = await availabilitySlots.find({ users: [user.id] });
expect(res).to.have.length(0);
});
it("should throw an error if the ids list is empty", async () => {
const res = await safe(async () => availabilitySlots.delete([]));
expect(res).to.be.instanceOf(Error);
});
});
});
29 changes: 29 additions & 0 deletions packages/types/src/availabilitySlot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type Self = {
id: number;
userId: number;
start: string;
end: string;
createAt: string;
updatedAt: string;
};

export type Row = {
id: number;
user_id: number;
start: Date;
end: Date;
created_at: Date;
updated_at: Date;
};

export type CreatePayload = {
userId: number;
start: string;
end: string;
};

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

2 changes: 1 addition & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export * as IUser from "@/user";
export * as ITutor from "@/tutor";
export * as ISubscription from "@/subscription";
export * as ISlot from "@/slot";
export * as IRoom from "@/room";
export * as IRating from "@/rating";
export * as IMessage from "@/message";
Expand All @@ -16,6 +15,7 @@ export * as IFilter from "@/filter";
export * as IInterview from "@/interview";
export * as Wss from "@/wss";
export * as IRule from "@/rule";
export * as IAvailabilitySlot from "@/availabilitySlot";
export * as IDate from "@/date";
export * as ILesson from "@/lesson";
export * as IWithdrawMethod from "@/withdrawMethod";
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export type Slot = {
duration: number;
};

export enum Repeat {
No = "no",
Daily = "daily",
Weekly = "weekly",
Monthly = "monthly",
}

export type FindRulesWithSlotsApiQuery = {
/**
* ISO UTC datetime.
Expand Down
Loading

0 comments on commit 6684773

Please sign in to comment.