diff --git a/packages/models/fixtures/db.ts b/packages/models/fixtures/db.ts index b488d194..3828178a 100644 --- a/packages/models/fixtures/db.ts +++ b/packages/models/fixtures/db.ts @@ -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(); diff --git a/packages/models/migrations/1716146586880_setup.js b/packages/models/migrations/1716146586880_setup.js index 867d88de..b3567bbd 100644 --- a/packages/models/migrations/1716146586880_setup.js +++ b/packages/models/migrations/1716146586880_setup.js @@ -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: { @@ -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"); @@ -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 }); @@ -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 }); diff --git a/packages/models/src/availabilitySlot.ts b/packages/models/src/availabilitySlot.ts new file mode 100644 index 00000000..31924919 --- /dev/null +++ b/packages/models/src/availabilitySlot.ts @@ -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 { + 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 { + 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 { + 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 + ): Promise { + const baseBuilder = this.applySearchFilter(this.builder(tx), { + users, + after, + before, + }); + const rows = await baseBuilder.clone().select(); + return rows.map(row => this.from(row)); + } + + applySearchFilter( + builder: Knex.QueryBuilder, + { + users, + after, + before, + }: SearchFilter + ): Knex.QueryBuilder { + 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(this.table) : knex(this.table); + } + + column(value: keyof IAvailabilitySlot.Row) { + return column(value, this.table); + } +} + +export const availabilitySlots = new AvailabilitySlots(); diff --git a/packages/models/tests/availabilitySlot.test.ts b/packages/models/tests/availabilitySlot.test.ts new file mode 100644 index 00000000..e5f5834f --- /dev/null +++ b/packages/models/tests/availabilitySlot.test.ts @@ -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); + }); + }); +}); diff --git a/packages/types/src/availabilitySlot.ts b/packages/types/src/availabilitySlot.ts new file mode 100644 index 00000000..fe1b22c0 --- /dev/null +++ b/packages/types/src/availabilitySlot.ts @@ -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; +}; + diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cc88e03b..a8ae35e8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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"; @@ -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"; diff --git a/packages/types/src/rule.ts b/packages/types/src/rule.ts index 14aa85fc..5bbe84ff 100644 --- a/packages/types/src/rule.ts +++ b/packages/types/src/rule.ts @@ -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. diff --git a/packages/types/src/slot.ts b/packages/types/src/slot.ts deleted file mode 100644 index 3b0cad74..00000000 --- a/packages/types/src/slot.ts +++ /dev/null @@ -1,73 +0,0 @@ -export enum Repeat { - No = "no", - Daily = "daily", - Weekly = "weekly", - Monthly = "monthly", -} - -export type Self = { - id: number; - userId: number; - title: string; - weekday: number; - time: { start: string; end: string }; - date: { start: string; end?: string }; - repeat: Repeat; - createdAt: string; - updatedAt: string; -}; - -export type ModifiedSelf = { - id: number; - userId: number; - title: string; - weekday: number; - start: string; - end: string | null; - time: string; - duration: number; - repeat: Repeat; - createdAt: string; - updatedAt: string; -}; - -export type Row = { - id: number; - user_id: number; - title: string; - weekday: number; - start_time: string; - end_time: string; - start_date: string; - end_date: string | null; - repeat: Repeat; - created_at: Date; - updated_at: Date; -}; - -export type Discrete = { - id: number; - userId: number; - title: string; - start: string; - end: string; - createdAt: string; - updatedAt: string; -}; - -export type SlotFilter = { - start?: string; - window?: number; -}; - -export type Unpacked = { - day: string; - slots: Discrete[]; -}; - -export type CreateApiPayload = { - title: string; - time: { start: string; end: string }; - date: { start: string; end?: string }; - repeat: Repeat; -}; diff --git a/services/server/src/validation/utils.ts b/services/server/src/validation/utils.ts index 0abbb674..5a11e34e 100644 --- a/services/server/src/validation/utils.ts +++ b/services/server/src/validation/utils.ts @@ -1,7 +1,7 @@ import { passwordRegex } from "@/constants"; import { IUser, - ISlot, + IRule, ISubscription, IDate, StringLiteral, @@ -80,10 +80,10 @@ export const birthYear = zod.coerce export const datetime = zod.coerce.string().datetime(); export const repeat = zod.enum([ - ISlot.Repeat.No, - ISlot.Repeat.Daily, - ISlot.Repeat.Weekly, - ISlot.Repeat.Monthly, + IRule.Repeat.No, + IRule.Repeat.Daily, + IRule.Repeat.Weekly, + IRule.Repeat.Monthly, ]); export const role = zod.enum([