From ad71d707afffbabcf871f2d944c554f8842fd9df Mon Sep 17 00:00:00 2001 From: "M. E. Abdelsalam" Date: Mon, 6 Jan 2025 02:47:40 +0200 Subject: [PATCH 1/3] add: find and set handlers for availability slots with unit tests. --- .../src/components/Lessons/BookLesson.tsx | 5 +- .../Interview/ScheduleInterview/index.tsx | 1 + packages/atlas/src/availabilitySlot.ts | 22 ++ .../Lessons/BookLesson/BookLessonDialog.tsx | 12 +- .../Lessons/BookLesson/TimeSelection.tsx | 19 +- packages/models/fixtures/db.ts | 22 +- .../models/migrations/1716146586880_setup.js | 14 + packages/models/scripts/seed.ts | 22 ++ packages/models/src/availabilitySlot.ts | 106 +++++-- packages/models/src/interviews.ts | 29 ++ packages/models/src/lessons.ts | 18 ++ packages/models/src/ratings.ts | 2 +- .../models/tests/availabilitySlot.test.ts | 34 ++- packages/sol/src/availabilitySlots.ts | 39 ++- packages/sol/src/rule.ts | 16 -- packages/types/src/api.ts | 2 + packages/types/src/availabilitySlot.ts | 48 +++- services/server/fixtures/db.ts | 33 ++- .../server/src/handlers/availabilitySlot.ts | 205 ++++++++++++++ services/server/src/handlers/interview.ts | 9 +- services/server/src/handlers/rule.ts | 12 +- services/server/src/lib/availabilitySlot.ts | 33 +++ services/server/src/lib/error.ts | 3 + .../server/src/routes/availabilitySlot.ts | 9 + services/server/src/routes/index.ts | 1 + services/server/src/routes/rule.ts | 1 - services/server/src/validation/utils.ts | 6 + services/server/tests/api/lesson.test.ts | 6 + services/server/tests/api/slot.test.ts | 260 ++++++++++++++++++ services/server/tests/wss/session.test.ts | 79 +++++- 30 files changed, 999 insertions(+), 69 deletions(-) create mode 100644 packages/atlas/src/availabilitySlot.ts create mode 100644 services/server/src/handlers/availabilitySlot.ts create mode 100644 services/server/src/lib/availabilitySlot.ts create mode 100644 services/server/src/routes/availabilitySlot.ts create mode 100644 services/server/tests/api/slot.test.ts diff --git a/apps/nova/src/components/Lessons/BookLesson.tsx b/apps/nova/src/components/Lessons/BookLesson.tsx index e4cfd54b8..8bda04af8 100644 --- a/apps/nova/src/components/Lessons/BookLesson.tsx +++ b/apps/nova/src/components/Lessons/BookLesson.tsx @@ -64,13 +64,16 @@ const BookLesson = ({ const onBook = useCallback( ({ ruleId, + slotId, start, duration, }: { ruleId: number; + slotId: number; start: string; duration: ILesson.Duration; - }) => bookLessonMutation.mutate({ tutorId, ruleId, start, duration }), + }) => + bookLessonMutation.mutate({ tutorId, ruleId, slotId, start, duration }), [bookLessonMutation, tutorId] ); diff --git a/apps/nova/src/components/TutorOnboardingSteps/Interview/ScheduleInterview/index.tsx b/apps/nova/src/components/TutorOnboardingSteps/Interview/ScheduleInterview/index.tsx index 46e4d6f50..b36617ff2 100644 --- a/apps/nova/src/components/TutorOnboardingSteps/Interview/ScheduleInterview/index.tsx +++ b/apps/nova/src/components/TutorOnboardingSteps/Interview/ScheduleInterview/index.tsx @@ -163,6 +163,7 @@ const ScheduleInterview: React.FC<{ mutation.mutate({ interviewerId: interviewer.data.id, ruleId: selectedRule.id, + slotId: selectedRule.id, start: selectedRule.start, }); }} diff --git a/packages/atlas/src/availabilitySlot.ts b/packages/atlas/src/availabilitySlot.ts new file mode 100644 index 000000000..b332fbf6f --- /dev/null +++ b/packages/atlas/src/availabilitySlot.ts @@ -0,0 +1,22 @@ +import { Base } from "@/base"; +import { IAvailabilitySlot } from "@litespace/types"; + +export class AvailabilitySlot extends Base { + async find( + params: IAvailabilitySlot.FindAvailabilitySlotsApiQuery + ): Promise { + return await this.get({ + route: `/api/v1/availability-slot/`, + params, + }); + } + + async set( + payload: IAvailabilitySlot.SetAvailabilitySlotsApiPayload + ): Promise { + return await this.post({ + route: `/api/v1/availability-slot/`, + payload, + }); + } +} diff --git a/packages/luna/src/components/Lessons/BookLesson/BookLessonDialog.tsx b/packages/luna/src/components/Lessons/BookLesson/BookLessonDialog.tsx index fb78e7d3f..72f24ab34 100644 --- a/packages/luna/src/components/Lessons/BookLesson/BookLessonDialog.tsx +++ b/packages/luna/src/components/Lessons/BookLesson/BookLessonDialog.tsx @@ -120,10 +120,12 @@ export const BookLessonDialog: React.FC<{ notice: number | null; onBook: ({ ruleId, + slotId, start, duration, }: { ruleId: number; + slotId: number; start: string; duration: ILesson.Duration; }) => void; @@ -145,6 +147,7 @@ export const BookLessonDialog: React.FC<{ const [duration, setDuration] = useState(15); const [start, setStart] = useState(null); const [ruleId, setRuleId] = useState(null); + const [slotId, setSlotId] = useState(null); const [date, setDate] = useState(dayjs()); const dateBounds = useMemo(() => { @@ -176,6 +179,7 @@ export const BookLessonDialog: React.FC<{ daySlots.map((rule) => splitRuleEvent(rule, duration)) ).map((event) => ({ ruleId: event.id, + slotId: event.id, start: event.start, end: event.end, bookable: dayjs(event.start).isAfter( @@ -205,6 +209,7 @@ export const BookLessonDialog: React.FC<{ const bookedSlots: AttributedSlot[] = slots .map((slot) => ({ ruleId: slot.ruleId, + slotId: slot.ruleId, start: slot.start, end: dayjs(slot.start).add(slot.duration, "minutes").toISOString(), bookable: false, @@ -290,9 +295,11 @@ export const BookLessonDialog: React.FC<{ slots={allSlots} start={start} ruleId={ruleId} - select={({ ruleId, start }) => { + slotId={ruleId} + select={({ slotId, ruleId, start }) => { setStart(start); setRuleId(ruleId); + setSlotId(slotId); }} /> @@ -302,6 +309,7 @@ export const BookLessonDialog: React.FC<{ step === "confirmation" && start && ruleId && + slotId && !loading ? (
@@ -312,7 +320,7 @@ export const BookLessonDialog: React.FC<{ start={start} confirmationLoading={confirmationLoading} duration={duration} - onConfrim={() => onBook({ ruleId, start, duration })} + onConfrim={() => onBook({ ruleId, slotId, start, duration })} onEdit={() => { setStep("date-selection"); }} diff --git a/packages/luna/src/components/Lessons/BookLesson/TimeSelection.tsx b/packages/luna/src/components/Lessons/BookLesson/TimeSelection.tsx index 7b39e3ead..c559e9d15 100644 --- a/packages/luna/src/components/Lessons/BookLesson/TimeSelection.tsx +++ b/packages/luna/src/components/Lessons/BookLesson/TimeSelection.tsx @@ -9,8 +9,9 @@ export const TimeSelection: React.FC<{ slots: AttributedSlot[]; start: string | null; ruleId: number | null; - select: (payload: { start: string; ruleId: number }) => void; -}> = ({ slots, start, ruleId, select }) => { + slotId: number | null; + select: (payload: { start: string; ruleId: number; slotId: number }) => void; +}> = ({ slots, start, ruleId, slotId, select }) => { return (
select({ ruleId: slot.ruleId, start: slot.start })} - data-selected={slot.start === start && slot.ruleId === ruleId} + onClick={() => + select({ + ruleId: slot.ruleId, + slotId: slot.ruleId, + start: slot.start, + }) + } + data-selected={ + slot.start === start && + slot.ruleId === ruleId && + slot.ruleId === slotId + } disabled={!slot.bookable} className={cn( "tw-bg-natural-50 tw-border tw-border-natural-800 tw-shadow-time-selection-item", diff --git a/packages/models/fixtures/db.ts b/packages/models/fixtures/db.ts index 0dfc2fea2..e3cb269a4 100644 --- a/packages/models/fixtures/db.ts +++ b/packages/models/fixtures/db.ts @@ -22,7 +22,7 @@ import { ISession, } from "@litespace/types"; import { faker } from "@faker-js/faker/locale/ar"; -import { entries, range, sample } from "lodash"; +import { entries, first, range, sample } from "lodash"; import { Knex } from "knex"; import dayjs from "@/lib/dayjs"; import { Time } from "@litespace/sol/time"; @@ -102,6 +102,22 @@ export async function rule(payload?: Partial) { }); } +export async function slot(payload?: Partial) { + const start = dayjs.utc(payload?.start || faker.date.future()); + const end = start.add(faker.number.int(8), "hours"); + + const slots = await availabilitySlots.create([ + { + userId: await or.tutorId(payload?.userId), + start: start.toISOString(), + end: end.toISOString(), + }, + ]); + const slot = first(slots); + if (!slot) throw new Error("Slot not found; should never happen"); + return slot; +} + const or = { async tutorId(id?: number): Promise { if (!id) return await tutor().then((tutor) => tutor.id); @@ -127,6 +143,10 @@ const or = { if (!roomId) return await makeRoom(); return roomId; }, + async slotId(id?: number): Promise { + if (!id) return await slot().then((slot) => slot.id); + return id; + }, start(start?: string): string { if (!start) return faker.date.soon().toISOString(); return start; diff --git a/packages/models/migrations/1716146586880_setup.js b/packages/models/migrations/1716146586880_setup.js index b40a0d8d0..4dea79100 100644 --- a/packages/models/migrations/1716146586880_setup.js +++ b/packages/models/migrations/1716146586880_setup.js @@ -94,6 +94,7 @@ exports.up = (pgm) => { user_id: { type: "INT", notNull: true, references: "users(id)" }, start: { type: "TIMESTAMP", notNull: true }, end: { type: "TIMESTAMP", notNull: true }, + deleted: { type: "BOOLEAN", notNull: true, default: false }, created_at: { type: "TIMESTAMP", notNull: true }, updated_at: { type: "TIMESTAMP", notNull: true }, }); @@ -104,6 +105,14 @@ exports.up = (pgm) => { duration: { type: "SMALLINT", notNull: true }, price: { type: "INT", notNull: true }, rule_id: { type: "SERIAL", references: "rules(id)", notNull: true }, +<<<<<<< HEAD +======= + slot_id: { + type: "SERIAL", + references: "availability_slots(id)", + notNull: true, + }, +>>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.) session_id: { type: "VARCHAR(50)", notNull: true, primaryKey: true }, canceled_by: { type: "INT", references: "users(id)", default: null }, canceled_at: { type: "TIMESTAMP", default: null }, @@ -125,6 +134,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, + }, note: { type: "TEXT", default: null }, level: { type: "INT", default: null }, status: { type: "interview_status", default: "pending" }, diff --git a/packages/models/scripts/seed.ts b/packages/models/scripts/seed.ts index 6f2d002e7..93106eced 100644 --- a/packages/models/scripts/seed.ts +++ b/packages/models/scripts/seed.ts @@ -285,6 +285,18 @@ async function main(): Promise { monthday: sample(range(1, 31)), }); +<<<<<<< HEAD +======= + // seeding slots + await availabilitySlots.create( + addedTutors.map((tutor) => ({ + userId: tutor.id, + start: dayjs.utc().startOf("day").toISOString(), + end: dayjs.utc().startOf("day").add(30, "days").toISOString(), + })) + ); + +>>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.) const times = range(0, 24).map((hour) => [hour.toString().padStart(2, "0"), "00"].join(":") ); @@ -402,6 +414,16 @@ async function main(): Promise { ); } + 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) => { const interview = await interviews.create({ diff --git a/packages/models/src/availabilitySlot.ts b/packages/models/src/availabilitySlot.ts index 603a32d35..3a1009cd2 100644 --- a/packages/models/src/availabilitySlot.ts +++ b/packages/models/src/availabilitySlot.ts @@ -2,10 +2,27 @@ import { IAvailabilitySlot } from "@litespace/types"; import dayjs from "@/lib/dayjs"; import { Knex } from "knex"; import { first, isEmpty } from "lodash"; +<<<<<<< HEAD:packages/models/src/availabilitySlot.ts import { knex, column, WithOptionalTx } from "@/query"; type SearchFilter = { /** +======= +import { + knex, + column, + countRows, + WithOptionalTx, + withSkippablePagination, +} from "@/query"; + +type SearchFilter = { + /** + * Slot ids to be included in the search query. + */ + slots?: number[]; + /** +>>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.):packages/models/src/availabilitySlots.ts * User ids to be included in the search query. */ users?: number[]; @@ -19,6 +36,11 @@ type SearchFilter = { * All slots before (or the same as) this date will be included. */ before?: string; +<<<<<<< HEAD:packages/models/src/availabilitySlot.ts +======= + deleted?: boolean; + pagination?: IFilter.SkippablePagination; +>>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.):packages/models/src/availabilitySlots.ts }; export class AvailabilitySlots { @@ -47,37 +69,38 @@ export class AvailabilitySlots { return rows.map((row) => this.from(row)); } - async delete( - ids: number[], - tx?: Knex.Transaction - ): Promise { + async delete(ids: number[], tx?: Knex.Transaction) { 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)); + await this.builder(tx).whereIn(this.column("id"), ids).del(); } async update( id: number, payload: IAvailabilitySlot.UpdatePayload, tx?: Knex.Transaction - ): Promise { - const rows = await this.builder(tx) + ) { + 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("*"); + .where("id", id); + } - const row = first(rows); - if (!row) throw new Error("Slot not found; should never happen."); - return this.from(row); + async markAsDeleted({ + ids, + tx, + }: WithOptionalTx<{ + ids: number[]; + }>): Promise { + const now = dayjs.utc().toDate(); + await this.builder(tx) + .update({ + deleted: true, + updated_at: now, + }) + .whereIn(this.column("id"), ids); } async find({ @@ -91,14 +114,56 @@ export class AvailabilitySlots { after, before, }); +<<<<<<< HEAD:packages/models/src/availabilitySlot.ts const rows = await baseBuilder.clone().select(); return rows.map((row) => this.from(row)); +======= + const total = await countRows(baseBuilder.clone()); + const rows = await withSkippablePagination(baseBuilder.clone(), pagination); + return { + list: rows.map((row) => this.from(row)), + total, + }; + } + + async findById( + id: number, + tx?: Knex.Transaction + ): Promise { + const { list } = await this.find({ slots: [id], tx }); + return first(list) || null; +>>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.):packages/models/src/availabilitySlots.ts + } + + async isOwner({ + slots, + owner, + tx, + }: WithOptionalTx<{ + slots: number[]; + owner: number; + }>): Promise { + if (isEmpty(slots)) return true; + 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 { + if (isEmpty(slots)) return true; + const builder = this.builder(tx).whereIn(this.column("id"), slots); + const count = await countRows(builder.clone()); + return Number(count) === slots.length; } applySearchFilter( builder: Knex.QueryBuilder, - { users, after, before }: SearchFilter + { slots, users, after, before, deleted }: SearchFilter ): Knex.QueryBuilder { + if (slots && !isEmpty(slots)) builder.whereIn(this.column("id"), slots); + if (users && !isEmpty(users)) builder.whereIn(this.column("user_id"), users); @@ -107,6 +172,8 @@ export class AvailabilitySlots { if (before) builder.where(this.column("end"), "<=", dayjs.utc(before).toDate()); + if (deleted) builder.where(this.column("deleted"), deleted); + return builder; } @@ -114,6 +181,7 @@ export class AvailabilitySlots { return { id: row.id, userId: row.user_id, + deleted: row.deleted, start: row.start.toISOString(), end: row.end.toISOString(), createAt: row.created_at.toISOString(), diff --git a/packages/models/src/interviews.ts b/packages/models/src/interviews.ts index 4aadad6af..c4f5222c1 100644 --- a/packages/models/src/interviews.ts +++ b/packages/models/src/interviews.ts @@ -82,6 +82,24 @@ export class Interviews { return this.from(row); } + async cancel({ + canceledBy, + ids, + tx, + }: WithOptionalTx<{ + ids: number[]; + canceledBy: number; + }>): Promise { + const now = dayjs.utc().toDate(); + await this.builder(tx) + .update({ + canceled_by: canceledBy, + canceled_at: now, + updated_at: now, + }) + .whereIn(this.column("id"), ids); + } + async findOneBy( key: T, value: IInterview.Row[T] @@ -139,7 +157,16 @@ export class Interviews { signed?: boolean; signers?: number[]; rules?: number[]; +<<<<<<< HEAD cancelled?: boolean; +======= + /** + * slots ids to be included in the query result + */ + slots?: number[]; + cancelled?: boolean; + pagination?: IFilter.SkippablePagination; +>>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.) } & IFilter.Pagination): Promise> { const baseBuilder = this.builder(tx); @@ -166,6 +193,8 @@ export class Interviews { if (!isEmpty(rules)) baseBuilder.whereIn(this.column("rule_id"), rules); + if (!isEmpty(slots)) baseBuilder.whereIn(this.column("slot_id"), slots); + if (cancelled === true) baseBuilder.where(this.column("canceled_at"), "IS NOT", null); else if (cancelled === false) diff --git a/packages/models/src/lessons.ts b/packages/models/src/lessons.ts index 5e79455fa..932fd13eb 100644 --- a/packages/models/src/lessons.ts +++ b/packages/models/src/lessons.ts @@ -151,6 +151,24 @@ export class Lessons { .where(this.columns.lessons("id"), id); } + async cancelBatch({ + canceledBy, + ids, + tx, + }: WithOptionalTx<{ + ids: number[]; + canceledBy: number; + }>): Promise { + const now = dayjs.utc().toDate(); + await this.builder(tx) + .lessons.update({ + canceled_by: canceledBy, + canceled_at: now, + updated_at: now, + }) + .whereIn(this.columns.lessons("id"), ids); + } + private async findOneBy( key: T, value: ILesson.Row[T] diff --git a/packages/models/src/ratings.ts b/packages/models/src/ratings.ts index 3fa7420d6..20abb6f97 100644 --- a/packages/models/src/ratings.ts +++ b/packages/models/src/ratings.ts @@ -1,4 +1,4 @@ -import { first, isEmpty, last } from "lodash"; +import { first } from "lodash"; import { column, countRows, diff --git a/packages/models/tests/availabilitySlot.test.ts b/packages/models/tests/availabilitySlot.test.ts index 1589e74bd..031498304 100644 --- a/packages/models/tests/availabilitySlot.test.ts +++ b/packages/models/tests/availabilitySlot.test.ts @@ -1,4 +1,5 @@ -import { availabilitySlots } from "@/availabilitySlot"; +import { availabilitySlots } from "@/availabilitySlots"; +import { knex } from "@/query"; import fixtures from "@fixtures/db"; import { dayjs, nameof, safe } from "@litespace/sol"; import { expect } from "chai"; @@ -102,13 +103,15 @@ describe("AvailabilitySlots", () => { }, ]); - const updated = await availabilitySlots.update(slots[0].id, { + 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); + const updated = await availabilitySlots.findById(slots[0].id); + + const res = await availabilitySlots.find({ users: [user.id] }); + expect(first(res.list)).to.deep.eq(updated); }); }); @@ -134,8 +137,29 @@ describe("AvailabilitySlots", () => { const res = await availabilitySlots.find({ users: [user.id] }); expect(res).to.have.length(0); }); + + 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([ + { + 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)) + ); + expect(res).to.be.instanceof(Error); + }); + 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); }); }); diff --git a/packages/sol/src/availabilitySlots.ts b/packages/sol/src/availabilitySlots.ts index 5995ab1b6..f432ee8a1 100644 --- a/packages/sol/src/availabilitySlots.ts +++ b/packages/sol/src/availabilitySlots.ts @@ -1,6 +1,7 @@ -import { IAvailabilitySlot } from "@litespace/types"; +import { IAvailabilitySlot, IInterview, ILesson } from "@litespace/types"; import { dayjs } from "@/dayjs"; import { flatten, orderBy } from "lodash"; +import { INTERVIEW_DURATION } from "./constants"; /** * Divide list of slots into sub-slots. It is used whenever a user wants to book a lesson @@ -191,3 +192,39 @@ export function orderSlots( ): IAvailabilitySlot.GeneralSlot[] { return orderBy(slots, [(slot) => dayjs.utc(slot.start)], [dir]); } + +export function asSlot( + item: T +): IAvailabilitySlot.Slot { + return { + id: "ids" in item ? item.ids.slot : item.slotId, + start: item.start, + end: dayjs(item.start) + .add("duration" in item ? item.duration : INTERVIEW_DURATION) + .toISOString(), + }; +} + +export function asSlots( + items: T[] +): IAvailabilitySlot.Slot[] { + return items.map((item) => asSlot(item)); +} + +export function asSubSlot( + item: T +): IAvailabilitySlot.SubSlot { + return { + parent: "ids" in item ? item.ids.slot : item.slotId, + start: item.start, + end: dayjs(item.start) + .add("duration" in item ? item.duration : INTERVIEW_DURATION) + .toISOString(), + }; +} + +export function asSubSlots( + items: T[] +): IAvailabilitySlot.SubSlot[] { + return items.map((item) => asSubSlot(item)); +} diff --git a/packages/sol/src/rule.ts b/packages/sol/src/rule.ts index 6881f8513..3169113a3 100644 --- a/packages/sol/src/rule.ts +++ b/packages/sol/src/rule.ts @@ -489,19 +489,3 @@ export function toUtcDate(value: string | Dayjs) { date.second() ); } - -export function asSlot( - item: T -): IRule.Slot { - return { - ruleId: "ids" in item ? item.ids.rule : item.ruleId, - start: item.start, - duration: "duration" in item ? item.duration : INTERVIEW_DURATION, - }; -} - -export function asSlots( - items: T[] -): IRule.Slot[] { - return items.map((item) => asSlot(item)); -} diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index ac0046a4c..6d233763c 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -17,6 +17,7 @@ export enum ApiError { StudentNotFound = "student-not-found", LessonNotFound = "lesson-not-found", RuleNotFound = "rule-not-found", + SlotNotFound = "slot-not-found", RatingNotFound = "rating-not-found", CouponNotFound = "coupon-not-found", AssetNotFound = "asset-not-found", @@ -38,6 +39,7 @@ export enum ApiError { EmptyRequest = "empty-request", UserAlreadyVerified = "user-already-verified", WrongPassword = "wrong-password", + Conflict = "conflict", } export type ApiErrorCode = ApiError | FieldError; diff --git a/packages/types/src/availabilitySlot.ts b/packages/types/src/availabilitySlot.ts index 6fb2e5658..c330d202e 100644 --- a/packages/types/src/availabilitySlot.ts +++ b/packages/types/src/availabilitySlot.ts @@ -1,8 +1,12 @@ +import { Paginated } from "@/utils"; +import { SkippablePagination } from "@/filter"; + export type Self = { id: number; userId: number; start: string; end: string; + deleted: boolean; createAt: string; updatedAt: string; }; @@ -12,6 +16,7 @@ export type Row = { user_id: number; start: Date; end: Date; + deleted: boolean; created_at: Date; updated_at: Date; }; @@ -23,8 +28,9 @@ export type CreatePayload = { }; export type UpdatePayload = { - start: string; - end: string; + start?: string; + end?: string; + deleted?: boolean; }; export type Slot = { @@ -40,3 +46,41 @@ export type SubSlot = { }; export type GeneralSlot = Slot | SubSlot; + +// API Payloads / Queries +export type FindAvailabilitySlotsApiQuery = { + userId: number; + after?: string; + before?: string; +} & { + pagination?: SkippablePagination; +}; + +export type CreateAction = { + action: "create"; + start: string; + end: string; +}; + +export type UpdateAction = { + action: "update"; + id: number; + start?: string; + end?: string; +}; + +export type DeleteAction = { + action: "delete"; + id: number; +}; + +// API Requests +export type SetAvailabilitySlotsApiPayload = { + slots: Array; +}; + +// API Responses +export type FindAvailabilitySlotsApiResponse = Paginated<{ + slots: Self[]; + subslots: SubSlot[]; +}>; diff --git a/services/server/fixtures/db.ts b/services/server/fixtures/db.ts index 7cef2ebc5..ac6ec73ed 100644 --- a/services/server/fixtures/db.ts +++ b/services/server/fixtures/db.ts @@ -102,6 +102,23 @@ export async function rule(payload?: Partial) { }); } +export async function slot(payload?: Partial) { + const start = dayjs.utc(payload?.start || faker.date.future()); + const end = payload?.end + ? 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]; +} + async function activatedRule(payload?: Partial) { const ruleInfo = await rule(payload); return await rules.update(ruleInfo.id, { activated: true }); @@ -124,14 +141,18 @@ const or = { async sessionId(type: ISession.Type): Promise { return `${type}:${randomUUID()}`; }, - async ruleId(id?: number): Promise { - if (!id) return await rule().then((rule) => rule.id); + async ruleId(id?: number, userId?: number): Promise { + if (!id) return await rule({ userId }).then((rule) => rule.id); return id; }, async roomId(roomId?: number): Promise { - if (!roomId) return makeRoom(); + if (!roomId) return await makeRoom(); return roomId; }, + async slotId(id?: number): Promise { + if (!id) return await slot().then((slot) => slot.id); + return id; + }, start(start?: string): string { if (!start) return faker.date.soon().toISOString(); return start; @@ -162,7 +183,8 @@ 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, tx, @@ -180,7 +202,8 @@ export async function interview(payload: Partial) { 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), }); } diff --git a/services/server/src/handlers/availabilitySlot.ts b/services/server/src/handlers/availabilitySlot.ts new file mode 100644 index 000000000..39e7815a5 --- /dev/null +++ b/services/server/src/handlers/availabilitySlot.ts @@ -0,0 +1,205 @@ +import { bad, conflict, forbidden, notfound } from "@/lib/error"; +import { datetime, id, skippablePagination } from "@/validation/utils"; +import { isTutor, isTutorManager, isUser } from "@litespace/auth"; +import { IAvailabilitySlot } from "@litespace/types"; +import { + availabilitySlots, + interviews, + knex, + lessons, +} from "@litespace/models"; +import dayjs from "@/lib/dayjs"; +import { NextFunction, Request, Response } from "express"; +import safeRequest from "express-async-handler"; +import { asSubSlots, isIntersecting } from "@litespace/sol"; +import zod from "zod"; +import { isEmpty } from "lodash"; +import { deleteSlots } from "@/lib/availabilitySlot"; + +const findPayload = zod.object({ + userId: id, + after: datetime, + before: datetime, + slotsOnly: zod.boolean().optional().default(false), + pagination: skippablePagination.optional(), +}); + +const setPayload = zod.object({ + slots: zod.array( + zod.union([ + zod.object({ + action: zod.literal("create"), + start: datetime, + end: datetime, + }), + zod.object({ + action: zod.literal("update"), + id: id, + start: datetime.optional(), + end: datetime.optional(), + }), + zod.object({ + action: zod.literal("delete"), + id: id, + }), + ]) + ), +}); + +async function find(req: Request, res: Response, next: NextFunction) { + const user = req.user; + if (!isUser(user)) return next(forbidden()); + + const { userId, after, before, slotsOnly, pagination } = findPayload.parse( + req.query + ); + + const diff = dayjs(before).diff(after, "days"); + if (diff < 0) return next(bad()); + const canUseFullFlag = + after && before && dayjs.utc(before).diff(after, "days") <= 30; + + const paginatedSlots = await availabilitySlots.find({ + users: [userId], + after, + before, + pagination: { + page: pagination?.page || 1, + size: pagination?.size || 10, + full: pagination?.full || !!canUseFullFlag, + }, + }); + + // return only-slots only if the user is a tutor + if (slotsOnly && (isTutor(user.role) || isTutorManager(user.role))) { + const result = { + list: [{ slots: paginatedSlots.list, subslots: [] }], + total: paginatedSlots.total, + }; + res.status(200).json(result); + return; + } + + const slotIds = paginatedSlots.list.map((slot) => slot.id); + + const paginatedLessons = await lessons.find({ + users: [userId], + slots: slotIds, + after, + before, + }); + + const userInterviews = await interviews.find({ + users: [userId], + slots: slotIds, + }); + + const result: IAvailabilitySlot.FindAvailabilitySlotsApiResponse = { + list: [ + { + slots: paginatedSlots.list, + subslots: asSubSlots([ + ...paginatedLessons.list, + ...userInterviews.list, + ]), + }, + ], + total: paginatedSlots.total, + }; + + res.status(200).json(result); +} + +/** + * This single endpoint is dedicated to create, update, and delete slots + * This is because when the user is preparing his schedule, he can create, + * delete, and update simultaneously. + */ +async function set(req: Request, res: Response, next: NextFunction) { + const user = req.user; + const allowed = isTutor(user) || isTutorManager(user); + if (!allowed) return next(forbidden()); + + const payload = setPayload.parse(req.body); + + const creates = payload.slots.filter( + (obj) => obj.action === "create" + ) as Array; + const updates = payload.slots.filter( + (obj) => obj.action === "update" + ) as Array; + const deletes = payload.slots.filter( + (obj) => obj.action === "delete" + ) as Array; + + const error = await knex.transaction(async (tx) => { + const deleteIds = deletes.map((obj) => obj.id); + const ids = [...deleteIds, ...updates.map((obj) => obj.id)]; + + const allExist = await availabilitySlots.allExist(ids, tx); + if (!allExist) return notfound.slot(); + + const isOwner = await availabilitySlots.isOwner({ + slots: ids, + owner: user.id, + tx, + }); + if (!isOwner) return forbidden(); + + // delete slots + await deleteSlots({ + currentUserId: user.id, + ids: deleteIds, + tx, + }); + + // update slots + for (const update of updates) { + await availabilitySlots.update( + update.id, + { + start: update.start, + end: update.end, + }, + tx + ); + } + + if (isEmpty(creates)) return; + + // check for confliction in creates list + const mySlots = await availabilitySlots.find({ + users: [user.id], + deleted: false, + }); + const intersecting = creates.find((create) => + isIntersecting( + { + id: 0, + start: create.start, + end: create.end, + }, + mySlots.list + ) + ); + if (intersecting) return conflict(); + + // create slots + await availabilitySlots.create( + creates.map(({ start, end }) => ({ + userId: user.id, + start, + end, + })), + tx + ); + }); + + if (error) return next(error); + res.sendStatus(200); +} + +export default { + find: safeRequest(find), + set: safeRequest(set), +}; diff --git a/services/server/src/handlers/interview.ts b/services/server/src/handlers/interview.ts index 7750356fb..b9da97937 100644 --- a/services/server/src/handlers/interview.ts +++ b/services/server/src/handlers/interview.ts @@ -6,7 +6,14 @@ import { notfound, } from "@/lib/error"; import { canBeInterviewed } from "@/lib/interview"; -import { interviews, rules, users, knex, rooms } from "@litespace/models"; +import { + interviews, + rules, + users, + knex, + rooms, + availabilitySlots, +} from "@litespace/models"; import { datetime, id, diff --git a/services/server/src/handlers/rule.ts b/services/server/src/handlers/rule.ts index eab0a85be..c6b218fa7 100644 --- a/services/server/src/handlers/rule.ts +++ b/services/server/src/handlers/rule.ts @@ -13,13 +13,7 @@ import { import { IRule, Wss } from "@litespace/types"; import { bad, contradictingRules, forbidden, notfound } from "@/lib/error"; import { interviews, lessons, rules, tutors } from "@litespace/models"; -import { - Rule, - Schedule, - asRule, - asSlots, - unpackRules, -} from "@litespace/sol/rule"; +import { Rule, Schedule, asRule, unpackRules } from "@litespace/sol/rule"; import { isEmpty } from "lodash"; import { ApiContext } from "@/types/api"; import dayjs from "@/lib/dayjs"; @@ -150,7 +144,9 @@ async function findUnpackedUserRules( /** * Respond with user's (e.g. tutor) IRule.Self objects that lay between two dates, * along with the occupied slots. + * @deprecated */ +/* async function findUserRulesWithSlots( req: Request, res: Response, @@ -198,6 +194,7 @@ async function findUserRulesWithSlots( res.status(200).json(response); } +*/ function updateRule(context: ApiContext) { return safeRequest( @@ -278,7 +275,6 @@ function deleteRule(context: ApiContext) { export default { findUnpackedUserRules: safeRequest(findUnpackedUserRules), findUserRules: safeRequest(findUserRules), - findUserRulesWithSlots: safeRequest(findUserRulesWithSlots), createRule, updateRule, deleteRule, diff --git a/services/server/src/lib/availabilitySlot.ts b/services/server/src/lib/availabilitySlot.ts new file mode 100644 index 000000000..92ce960c4 --- /dev/null +++ b/services/server/src/lib/availabilitySlot.ts @@ -0,0 +1,33 @@ +import { availabilitySlots, interviews, lessons } from "@litespace/models"; +import { Knex } from "knex"; +import { isEmpty } from "lodash"; + +export async function deleteSlots({ + currentUserId, + ids, + tx, +}: { + currentUserId: number; + ids: number[]; + tx?: Knex.Transaction; +}): Promise { + if (isEmpty(ids)) return; + + const associatedLessons = await lessons.find({ slots: ids, tx }); + const associatedInterviews = await interviews.find({ slots: ids, tx }); + const associatesCount = associatedLessons.total + associatedInterviews.total; + + if (associatesCount <= 0) return await availabilitySlots.delete(ids, tx); + + await lessons.cancelBatch({ + ids: associatedLessons.list.map((l) => l.id), + canceledBy: currentUserId, + tx, + }); + await interviews.cancel({ + ids: associatedInterviews.list.map((i) => i.ids.self), + canceledBy: currentUserId, + tx, + }); + await availabilitySlots.markAsDeleted({ ids: ids, tx }); +} diff --git a/services/server/src/lib/error.ts b/services/server/src/lib/error.ts index ead6f91f2..8553a40a4 100644 --- a/services/server/src/lib/error.ts +++ b/services/server/src/lib/error.ts @@ -36,6 +36,8 @@ export const refused = () => export const bad = () => error(ApiError.BadRequest, "Bad request", 400); +export const conflict = () => error(ApiError.Conflict, "Conflict", 409); + export const empty = () => error(ApiError.EmptyRequest, "Empty request", 400); export const busyTutor = () => @@ -83,6 +85,7 @@ export const notfound = { base: () => error(ApiError.NotFound, "Resource not found", 404), user: () => error(ApiError.UserNotFound, "User not found", 404), rule: () => error(ApiError.RuleNotFound, "Rule not found", 404), + slot: () => error(ApiError.SlotNotFound, "Slot not found", 404), tutor: () => error(ApiError.TutorNotFound, "Tutor not found", 404), student: () => error(ApiError.StudentNotFound, "Student not found", 404), session: () => error(ApiError.SessionNotFound, "Session not found", 404), diff --git a/services/server/src/routes/availabilitySlot.ts b/services/server/src/routes/availabilitySlot.ts new file mode 100644 index 000000000..5a480b30c --- /dev/null +++ b/services/server/src/routes/availabilitySlot.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import availabilitySlot from "@/handlers/availabilitySlot"; + +const router = Router(); + +router.get("/", availabilitySlot.find); +router.post("/", availabilitySlot.set); + +export default router; diff --git a/services/server/src/routes/index.ts b/services/server/src/routes/index.ts index e4d511857..7590e778d 100644 --- a/services/server/src/routes/index.ts +++ b/services/server/src/routes/index.ts @@ -36,4 +36,5 @@ export default { topic, cache, session, + availabilitySlot, }; diff --git a/services/server/src/routes/rule.ts b/services/server/src/routes/rule.ts index a23b2dd6a..8acbdc962 100644 --- a/services/server/src/routes/rule.ts +++ b/services/server/src/routes/rule.ts @@ -10,7 +10,6 @@ export default function router(context: ApiContext) { router.delete("/:ruleId", rule.deleteRule(context)); router.get("/list/:userId", rule.findUserRules); router.get("/list/unpacked/:userId", rule.findUnpackedUserRules); - router.get("/slots/:userId", rule.findUserRulesWithSlots); return router; } diff --git a/services/server/src/validation/utils.ts b/services/server/src/validation/utils.ts index 1c6867226..a626e2c71 100644 --- a/services/server/src/validation/utils.ts +++ b/services/server/src/validation/utils.ts @@ -147,3 +147,9 @@ export const pagination = zod.object({ page: zod.optional(pageNumber).default(1), size: zod.optional(pageSize).default(10), }); + +export const skippablePagination = zod.object({ + page: zod.optional(pageNumber).default(1), + size: zod.optional(pageSize).default(10), + full: zod.optional(boolean).default(false), +}); diff --git a/services/server/tests/api/lesson.test.ts b/services/server/tests/api/lesson.test.ts index 2b98e2fc0..92b2b2a60 100644 --- a/services/server/tests/api/lesson.test.ts +++ b/services/server/tests/api/lesson.test.ts @@ -41,6 +41,12 @@ describe("/api/v1/lesson/", () => { title: faker.lorem.words(3), }); + const slot = await db.slot({ + userId: tutor.user.id, + start: dayjs.utc().startOf("day").toISOString(), + end: dayjs.utc().add(10, "day").startOf("day").toISOString(), + }); + const unpackedRules = unpackRules({ rules: [rule], slots: [], diff --git a/services/server/tests/api/slot.test.ts b/services/server/tests/api/slot.test.ts new file mode 100644 index 000000000..0d563ad87 --- /dev/null +++ b/services/server/tests/api/slot.test.ts @@ -0,0 +1,260 @@ +import { Api } from "@fixtures/api"; +import db from "@fixtures/db"; +import dayjs from "@/lib/dayjs"; +import { expect } from "chai"; +import { safe } from "@litespace/sol"; +import { bad, conflict, forbidden, notfound } from "@/lib/error"; +import { availabilitySlots } from "@litespace/models"; +import { first } from "lodash"; + +async function genMockData(tutorId: number, datetime: dayjs.Dayjs) { + const slot1 = await db.slot({ + userId: tutorId, + start: datetime.toISOString(), + end: datetime.add(1, "day").toISOString(), + }); + const slot2 = await db.slot({ + userId: tutorId, + start: datetime.add(1, "day").toISOString(), + end: datetime.add(2, "day").toISOString(), + }); + + const lesson1 = await db.lesson({ + tutor: tutorId, + start: datetime.add(6, "hours").toISOString(), + duration: 45, + slot: slot1.id, + }); + const lesson2 = await db.lesson({ + tutor: tutorId, + start: datetime.add(26, "hour").toISOString(), + duration: 90, + slot: slot2.id, + }); + + const interview1 = await db.interview({ + interviewer: tutorId, + start: datetime.add(1, "hour").toISOString(), + slot: slot1.id, + }); + + return { + slots: [slot1, slot2], + lessons: [lesson1, lesson2], + interviews: [interview1], + }; +} + +describe("/api/v1/availability-slot/", () => { + beforeEach(async () => { + await db.flush(); + }); + + describe("GET api/v1/availability-slot/:userId", () => { + it("should retrieve the available slots with the already booked subslots of a specific user", async () => { + const studentApi = await Api.forStudent(); + const tutor = await db.tutor(); + + const now = dayjs.utc(); + const mock = await genMockData(tutor.id, now); + + const res = await studentApi.atlas.availabilitySlot.find({ + userId: tutor.id, + after: now.toISOString(), + before: now.add(2, "days").toISOString(), + pagination: {}, + }); + + const { slots, subslots } = res.list[0]; + expect(res.total).to.eq(2); + expect(slots).to.deep.eq(mock.slots); + expect(subslots).to.have.length( + mock.lessons.length + mock.interviews.length + ); + }); + + it("should respond with bad request if the `after` date is before the `before` date", async () => { + const studentApi = await Api.forStudent(); + const tutor = await db.tutor(); + + const now = dayjs.utc(); + + const res = await safe(async () => + studentApi.atlas.availabilitySlot.find({ + userId: tutor.id, + after: now.add(2, "days").toISOString(), + before: now.toISOString(), + pagination: {}, + }) + ); + + expect(res).to.deep.eq(bad()); + }); + }); + + describe("POST api/v1/availability-slot/set", () => { + it("should respond with forbidden if the requester is not a tutor", async () => { + const studentApi = await Api.forStudent(); + const now = dayjs.utc(); + const res = await safe(async () => + studentApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "create", + start: now.toISOString(), + end: now.add(6, "hours").toISOString(), + }, + ], + }) + ); + expect(res).to.deep.eq(forbidden()); + }); + + it("should successfully create a new slot", async () => { + const tutorApi = await Api.forTutor(); + const tutor = await tutorApi.findCurrentUser(); + + const now = dayjs.utc(); + await tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "create", + start: now.toISOString(), + end: now.add(6, "hours").toISOString(), + }, + ], + }); + + const slots = await availabilitySlots.find({ users: [tutor.user.id] }); + expect(slots.total).to.eq(1); + + const c1 = { + userId: tutor.user.id, + start: now.toISOString(), + end: now.add(6, "hours").toISOString(), + }; + const c2 = { + userId: slots.list[0].userId, + start: slots.list[0].start, + end: slots.list[0].end, + }; + expect(c1).to.deep.eq(c2); + }); + + it("should respond with conflict when creating a new slot that intersects with already existing one", async () => { + const tutorApi = await Api.forTutor(); + const tutor = await tutorApi.findCurrentUser(); + const now = dayjs.utc(); + await genMockData(tutor.user.id, now); + + const res = await safe(async () => + tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "create", + start: now.toISOString(), + end: now.add(6, "hours").toISOString(), + }, + ], + }) + ); + + expect(res).to.deep.eq(conflict()); + }); + + it("should successfully update an existing slot", async () => { + const tutorApi = await Api.forTutor(); + const tutor = await tutorApi.findCurrentUser(); + const now = dayjs.utc(); + const mock = await genMockData(tutor.user.id, now); + + const newSlotData = { + id: mock.slots[0].id, + start: now.add(2, "days").toISOString(), + end: now.add(3, "days").toISOString(), + }; + + await tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "update", + ...newSlotData, + }, + ], + }); + + const slots = await availabilitySlots.find({ slots: [mock.slots[0].id] }); + expect(newSlotData.start).to.eq(first(slots.list)?.start); + expect(newSlotData.end).to.eq(first(slots.list)?.end); + }); + + it("should successfully delete an existing slot", async () => { + const tutorApi = await Api.forTutor(); + const tutor = await tutorApi.findCurrentUser(); + const now = dayjs.utc(); + const mock = await genMockData(tutor.user.id, now); + + await tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "delete", + id: mock.slots[0].id, + }, + ], + }); + + const slots = await availabilitySlots.find({ slots: [mock.slots[0].id] }); + expect(first(slots.list)?.deleted).to.be.true; + }); + + it("should respond with notfound if any of the passed slots is not found", async () => { + const tutorApi = await Api.forTutor(); + const tutor = await tutorApi.findCurrentUser(); + const now = dayjs.utc(); + const mock = await genMockData(tutor.user.id, now); + + const res = await safe(async () => + tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "delete", + id: mock.slots[0].id, + }, + { + action: "update", + id: mock.slots[1].id, + start: now.add(2, "days").toISOString(), + end: now.add(3, "days").toISOString(), + }, + { + action: "delete", + id: 123, + }, + ], + }) + ); + + expect(res).to.deep.eq(notfound.slot()); + }); + + it("should respond with forbidden if the requester is not the owner of any passed update/delete actions slot", async () => { + const tutorApi = await Api.forTutor(); + const tutor = await db.tutor(); + const now = dayjs.utc(); + const mock = await genMockData(tutor.id, now); + + const res = await safe(async () => + tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "delete", + id: mock.slots[0].id, + }, + ], + }) + ); + + expect(res).to.deep.eq(forbidden()); + }); + }); +}); diff --git a/services/server/tests/wss/session.test.ts b/services/server/tests/wss/session.test.ts index e69333823..189fb7dbc 100644 --- a/services/server/tests/wss/session.test.ts +++ b/services/server/tests/wss/session.test.ts @@ -59,10 +59,17 @@ describe("sessions test suite", () => { const selectedRuleEvent = unpackedRules[0]; + const slot = await db.slot({ + userId: tutor.user.id, + start: now.utc().startOf("day").toISOString(), + end: now.utc().add(10, "day").startOf("day").toISOString(), + }); + const lesson = await studentApi.atlas.lesson.create({ start: selectedRuleEvent.start, duration: 30, ruleId: rule.id, + slotId: slot.id, tutorId: tutor.user.id, }); @@ -100,6 +107,12 @@ describe("sessions test suite", () => { title: faker.lorem.words(3), }); + const slot = await db.slot({ + userId: tutor.user.id, + start: now.utc().startOf("day").toISOString(), + end: now.utc().add(10, "day").startOf("day").toISOString(), + }); + const unpackedRules = unpackRules({ rules: [rule], slots: [], @@ -152,6 +165,63 @@ describe("sessions test suite", () => { title: faker.lorem.words(3), }); + const slot = await db.slot({ + userId: tutor.user.id, + start: now.utc().startOf("day").toISOString(), + end: now.utc().add(10, "day").startOf("day").toISOString(), + }); + + const unpackedRules = unpackRules({ + rules: [rule], + slots: [], + start: rule.start, + end: rule.end, + }); + const selectedRuleEvent = unpackedRules[0]; + + const lesson = await studentApi.atlas.lesson.create({ + start: selectedRuleEvent.start, + duration: 30, + ruleId: rule.id, + tutorId: tutor.user.id, + }); + + const newTutorApi = await Api.forTutor(); + const newTutor = await newTutorApi.findCurrentUser(); + + const tutorSocket = new ClientSocket(newTutor.token); + const studentSocket = new ClientSocket(student.token); + + const result = studentSocket.wait(Wss.ServerEvent.MemberJoinedSession); + tutorSocket.joinSession(lesson.sessionId); + + await result + .then(() => expect(false)) + .catch((e) => expect(e.message).to.be.eq("TIMEOUT")); + + tutorSocket.client.disconnect(); + studentSocket.client.disconnect(); + }); + + it("should NOT broadcast when user tries to join a session before its start", async () => { + const now = dayjs(); + const rule = await tutorApi.atlas.rule.create({ + start: now.add(1, "day").utc().startOf("day").toISOString(), + end: now.utc().add(10, "day").startOf("day").toISOString(), + duration: 8 * 60, + frequency: IRule.Frequency.Daily, + time: Time.from(now.hour() + ":" + now.minute()) + .utc() + .format("railway"), + title: faker.lorem.words(3), + }); + + const slot = await db.slot({ + userId: tutor.user.id, + start: now.add(1, "day").utc().startOf("day").toISOString(), + end: now.utc().add(10, "day").startOf("day").toISOString(), + }); + const unpackedRules = unpackRules({ rules: [rule], slots: [], @@ -164,6 +234,7 @@ describe("sessions test suite", () => { start: selectedRuleEvent.start, duration: 30, ruleId: rule.id, + slotId: slot.id, tutorId: tutor.user.id, }); @@ -173,8 +244,12 @@ describe("sessions test suite", () => { const tutorSocket = new ClientSocket(newTutor.token); const studentSocket = new ClientSocket(student.token); - const ack = await tutorSocket.joinSession(lesson.sessionId); - expect(ack.code).to.eq(Wss.AcknowledgeCode.Unallowed); + const result = studentSocket.wait(Wss.ServerEvent.MemberJoinedSession); + tutorSocket.joinSession(lesson.sessionId); + + await result + .then(() => expect(false)) + .catch((e) => expect(e.message).to.be.eq("TIMEOUT")); tutorSocket.client.disconnect(); studentSocket.client.disconnect(); From 09ffab34a1e793d4712294c97766ea42d7a1da8d Mon Sep 17 00:00:00 2001 From: "M. E. Abdelsalam" Date: Tue, 14 Jan 2025 01:31:18 +0200 Subject: [PATCH 2/3] fix: rebased on master and errors have been fixed. --- packages/atlas/src/atlas.ts | 3 + packages/headless/src/lessons.ts | 3 + packages/models/fixtures/db.ts | 24 +++- .../models/migrations/1716146586880_setup.js | 3 - packages/models/scripts/seed.ts | 8 +- ...ailabilitySlot.ts => availabilitySlots.ts} | 29 ++-- packages/models/src/index.ts | 1 + packages/models/src/interviews.ts | 12 +- packages/models/src/lessons.ts | 31 ++--- .../models/tests/availabilitySlot.test.ts | 18 +-- packages/sol/src/availabilitySlots.ts | 2 +- packages/types/src/availabilitySlot.ts | 6 +- packages/types/src/interview.ts | 4 + packages/types/src/lesson.ts | 4 + services/server/fixtures/db.ts | 51 ++++--- services/server/src/constants.ts | 2 + .../server/src/handlers/availabilitySlot.ts | 127 ++++++++---------- services/server/src/handlers/interview.ts | 7 +- services/server/src/handlers/lesson.ts | 8 +- services/server/src/index.ts | 1 + services/server/src/lib/availabilitySlot.ts | 52 +++++-- services/server/src/routes/index.ts | 1 + services/server/tests/api/chat.test.ts | 3 +- services/server/tests/api/lesson.test.ts | 15 +++ services/server/tests/api/rule.test.ts | 2 +- services/server/tests/api/slot.test.ts | 51 ++++++- services/server/tests/api/user.test.ts | 22 +-- services/server/tests/wss/session.test.ts | 6 +- 28 files changed, 306 insertions(+), 190 deletions(-) rename packages/models/src/{availabilitySlot.ts => availabilitySlots.ts} (83%) diff --git a/packages/atlas/src/atlas.ts b/packages/atlas/src/atlas.ts index f233fefe7..7fa28f7c0 100644 --- a/packages/atlas/src/atlas.ts +++ b/packages/atlas/src/atlas.ts @@ -1,5 +1,6 @@ import { User } from "@/user"; import { Auth } from "@/auth"; +import { AvailabilitySlot } from "@/availabilitySlot"; import { Backend } from "@litespace/types"; import { Plan } from "@/plan"; import { Coupon } from "@/coupon"; @@ -22,6 +23,7 @@ import { Session } from "@/session"; export class Atlas { public readonly user: User; public readonly auth: Auth; + public readonly availabilitySlot: AvailabilitySlot; public readonly plan: Plan; public readonly coupon: Coupon; public readonly invite: Invite; @@ -42,6 +44,7 @@ export class Atlas { constructor(backend: Backend, token: AuthToken | null) { this.user = new User(backend, token); this.auth = new Auth(backend, token); + this.availabilitySlot = new AvailabilitySlot(backend, token); this.plan = new Plan(backend, token); this.coupon = new Coupon(backend, token); this.invite = new Invite(backend, token); diff --git a/packages/headless/src/lessons.ts b/packages/headless/src/lessons.ts index e20340b6b..60a0eae4a 100644 --- a/packages/headless/src/lessons.ts +++ b/packages/headless/src/lessons.ts @@ -90,11 +90,13 @@ export function useCreateLesson({ async ({ tutorId, ruleId, + slotId, start, duration, }: { tutorId: number; ruleId: number; + slotId: number; start: string; duration: ILesson.Duration; }) => { @@ -102,6 +104,7 @@ export function useCreateLesson({ tutorId, duration, ruleId, + slotId, start, }); }, diff --git a/packages/models/fixtures/db.ts b/packages/models/fixtures/db.ts index e3cb269a4..88924294c 100644 --- a/packages/models/fixtures/db.ts +++ b/packages/models/fixtures/db.ts @@ -10,6 +10,7 @@ import { users, ratings, tutors, + availabilitySlots, } from "@/index"; import { IInterview, @@ -20,18 +21,17 @@ import { IRating, IMessage, ISession, + IAvailabilitySlot, } from "@litespace/types"; import { faker } from "@faker-js/faker/locale/ar"; import { entries, first, range, sample } from "lodash"; import { Knex } from "knex"; import dayjs from "@/lib/dayjs"; import { Time } from "@litespace/sol/time"; -import { availabilitySlots } from "@/availabilitySlot"; import { randomUUID } from "crypto"; 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(); @@ -44,6 +44,7 @@ export async function flush() { await rules.builder(tx).del(); await ratings.builder(tx).del(); await tutors.builder(tx).del(); + await availabilitySlots.builder(tx).del(); await users.builder(tx).del(); }); } @@ -178,13 +179,18 @@ export async function lesson( duration: payload?.duration || sample([15, 30]), price: payload?.price || faker.number.int(500), rule: await or.ruleId(payload?.rule), + slot: await or.slotId(payload?.slot), student, tutor, tx, }); if (payload?.canceled) - await lessons.cancel({ canceledBy: tutor, id: data.lesson.id, tx }); + await lessons.cancel({ + canceledBy: tutor, + ids: [data.lesson.id], + tx, + }); return data; }); @@ -196,6 +202,7 @@ export async function interview(payload: Partial) { interviewee: await or.tutorId(payload.interviewee), session: await or.sessionId("interview"), rule: await or.ruleId(payload.rule), + slot: await or.slotId(payload.slot), start: or.start(payload.start), }); } @@ -312,7 +319,10 @@ async function makeLessons({ range(0, canceledFutureLessonCount).map(async (i) => { const info = futureLessons[i]; if (!lesson) throw new Error("invalid future lesson index"); - await lessons.cancel({ canceledBy: tutor, id: info.lesson.id }); + await lessons.cancel({ + canceledBy: tutor, + ids: [info.lesson.id], + }); return info; }) ); @@ -322,7 +332,10 @@ async function makeLessons({ range(0, canceledPastLessonCount).map(async (i) => { const info = pastLessons[i]; if (!info) throw new Error("invalid past lesson index"); - await lessons.cancel({ canceledBy: tutor, id: info.lesson.id }); + await lessons.cancel({ + canceledBy: tutor, + ids: [info.lesson.id], + }); return info; }) ); @@ -464,6 +477,7 @@ export default { flush, topic, rule, + slot, room: makeRoom, rating: makeRating, message: makeMessage, diff --git a/packages/models/migrations/1716146586880_setup.js b/packages/models/migrations/1716146586880_setup.js index 4dea79100..431299a9b 100644 --- a/packages/models/migrations/1716146586880_setup.js +++ b/packages/models/migrations/1716146586880_setup.js @@ -105,14 +105,11 @@ exports.up = (pgm) => { duration: { type: "SMALLINT", notNull: true }, price: { type: "INT", notNull: true }, rule_id: { type: "SERIAL", references: "rules(id)", notNull: true }, -<<<<<<< HEAD -======= slot_id: { type: "SERIAL", references: "availability_slots(id)", notNull: true, }, ->>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.) session_id: { type: "VARCHAR(50)", notNull: true, primaryKey: true }, canceled_by: { type: "INT", references: "users(id)", default: null }, canceled_at: { type: "TIMESTAMP", default: null }, diff --git a/packages/models/scripts/seed.ts b/packages/models/scripts/seed.ts index 93106eced..0f6735c89 100644 --- a/packages/models/scripts/seed.ts +++ b/packages/models/scripts/seed.ts @@ -17,6 +17,7 @@ import { invoices, hashPassword, topics, + availabilitySlots, } from "@litespace/models"; import { IInterview, ILesson, IUser, IWithdrawMethod } from "@litespace/types"; import dayjs from "dayjs"; @@ -285,8 +286,6 @@ async function main(): Promise { monthday: sample(range(1, 31)), }); -<<<<<<< HEAD -======= // seeding slots await availabilitySlots.create( addedTutors.map((tutor) => ({ @@ -296,7 +295,6 @@ async function main(): Promise { })) ); ->>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.) const times = range(0, 24).map((hour) => [hour.toString().padStart(2, "0"), "00"].join(":") ); @@ -379,12 +377,13 @@ async function main(): Promise { start, duration, rule: 1, + slot: 1, tx, }); if (sample([0, 1])) await lessons.cancel({ - id: lesson.id, + ids: [lesson.id], canceledBy: sample([tutorId, student.id]), tx, }); @@ -432,6 +431,7 @@ async function main(): Promise { interviewer: tutorManager.id, start: randomStart(), rule: rule.id, + slot: slot.id, tx, }); diff --git a/packages/models/src/availabilitySlot.ts b/packages/models/src/availabilitySlots.ts similarity index 83% rename from packages/models/src/availabilitySlot.ts rename to packages/models/src/availabilitySlots.ts index 3a1009cd2..344d1d53f 100644 --- a/packages/models/src/availabilitySlot.ts +++ b/packages/models/src/availabilitySlots.ts @@ -1,13 +1,7 @@ -import { IAvailabilitySlot } from "@litespace/types"; +import { IAvailabilitySlot, IFilter, Paginated } from "@litespace/types"; import dayjs from "@/lib/dayjs"; import { Knex } from "knex"; import { first, isEmpty } from "lodash"; -<<<<<<< HEAD:packages/models/src/availabilitySlot.ts -import { knex, column, WithOptionalTx } from "@/query"; - -type SearchFilter = { - /** -======= import { knex, column, @@ -22,7 +16,6 @@ type SearchFilter = { */ slots?: number[]; /** ->>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.):packages/models/src/availabilitySlots.ts * User ids to be included in the search query. */ users?: number[]; @@ -36,11 +29,8 @@ type SearchFilter = { * All slots before (or the same as) this date will be included. */ before?: string; -<<<<<<< HEAD:packages/models/src/availabilitySlot.ts -======= deleted?: boolean; pagination?: IFilter.SkippablePagination; ->>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.):packages/models/src/availabilitySlots.ts }; export class AvailabilitySlots { @@ -106,18 +96,17 @@ export class AvailabilitySlots { async find({ tx, users, + slots, after, before, - }: WithOptionalTx): Promise { + pagination, + }: WithOptionalTx): Promise> { const baseBuilder = this.applySearchFilter(this.builder(tx), { users, + slots, after, before, }); -<<<<<<< HEAD:packages/models/src/availabilitySlot.ts - const rows = await baseBuilder.clone().select(); - return rows.map((row) => this.from(row)); -======= const total = await countRows(baseBuilder.clone()); const rows = await withSkippablePagination(baseBuilder.clone(), pagination); return { @@ -132,7 +121,6 @@ export class AvailabilitySlots { ): Promise { const { list } = await this.find({ slots: [id], tx }); return first(list) || null; ->>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.):packages/models/src/availabilitySlots.ts } async isOwner({ @@ -151,9 +139,14 @@ export class AvailabilitySlots { return count === slots.length; } + /** + * NOTE: this function filters out the marked-as-deleted rows + */ async allExist(slots: number[], tx?: Knex.Transaction): Promise { if (isEmpty(slots)) return true; - const builder = this.builder(tx).whereIn(this.column("id"), slots); + const builder = this.builder(tx) + .where(this.column("deleted"), false) + .whereIn(this.column("id"), slots); const count = await countRows(builder.clone()); return Number(count) === slots.length; } diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index d52b615a6..22102873a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -1,5 +1,6 @@ export { coupons } from "@/coupons"; export { interviews } from "@/interviews"; +export { availabilitySlots } from "@/availabilitySlots"; export { invites } from "@/invites"; export { lessons } from "@/lessons"; export { messages } from "@/messages"; diff --git a/packages/models/src/interviews.ts b/packages/models/src/interviews.ts index c4f5222c1..010f3a588 100644 --- a/packages/models/src/interviews.ts +++ b/packages/models/src/interviews.ts @@ -21,6 +21,7 @@ export class Interviews { interviewer_feedback: this.column("interviewer_feedback"), interviewee_feedback: this.column("interviewee_feedback"), rule_id: this.column("rule_id"), + slot_id: this.column("slot_id"), session_id: this.column("session_id"), note: this.column("note"), level: this.column("level"), @@ -44,6 +45,7 @@ export class Interviews { interviewee_id: payload.interviewee, session_id: payload.session, rule_id: payload.rule, + slot_id: payload.slot, created_at: now, updated_at: now, }) @@ -138,6 +140,10 @@ export class Interviews { return await this.findManyBy("rule_id", id); } + async findBySlotId(id: number): Promise { + return await this.findManyBy("slot_id", id); + } + async find({ statuses, signers, @@ -147,6 +153,7 @@ export class Interviews { page, size, rules = [], + slots = [], cancelled, tx, }: { @@ -157,16 +164,12 @@ export class Interviews { signed?: boolean; signers?: number[]; rules?: number[]; -<<<<<<< HEAD - cancelled?: boolean; -======= /** * slots ids to be included in the query result */ slots?: number[]; cancelled?: boolean; pagination?: IFilter.SkippablePagination; ->>>>>>> 1861159d (add: find and set handlers for availability slots with unit tests.) } & IFilter.Pagination): Promise> { const baseBuilder = this.builder(tx); @@ -217,6 +220,7 @@ export class Interviews { interviewer: row.interviewer_id, interviewee: row.interviewee_id, rule: row.rule_id, + slot: row.slot_id, session: row.session_id, }, feedback: { diff --git a/packages/models/src/lessons.ts b/packages/models/src/lessons.ts index 932fd13eb..542a87354 100644 --- a/packages/models/src/lessons.ts +++ b/packages/models/src/lessons.ts @@ -56,6 +56,10 @@ type SearchFilter = { * Filter only lessons that blogs to the provided rule ids. */ rules?: number[]; + /** + * Filter only lessons that blogs to the provided slot ids. + */ + slots?: number[]; }; type BaseAggregateParams = SearchFilter & { tx?: Knex.Transaction }; @@ -88,6 +92,7 @@ export class Lessons { duration: this.columns.lessons("duration"), price: this.columns.lessons("price"), rule_id: this.columns.lessons("rule_id"), + slot_id: this.columns.lessons("slot_id"), session_id: this.columns.lessons("session_id"), canceled_by: this.columns.lessons("canceled_by"), canceled_at: this.columns.lessons("canceled_at"), @@ -108,6 +113,7 @@ export class Lessons { start: dayjs.utc(payload.start).toDate(), duration: payload.duration, rule_id: payload.rule, + slot_id: payload.slot, session_id: payload.session, price: payload.price, created_at: now, @@ -134,24 +140,6 @@ export class Lessons { } async cancel({ - canceledBy, - id, - tx, - }: WithOptionalTx<{ - id: number; - canceledBy: number; - }>): Promise { - const now = dayjs.utc().toDate(); - await this.builder(tx) - .lessons.update({ - canceled_by: canceledBy, - canceled_at: now, - updated_at: now, - }) - .where(this.columns.lessons("id"), id); - } - - async cancelBatch({ canceledBy, ids, tx, @@ -254,6 +242,7 @@ export class Lessons { after, before, rules, + slots, ...pagination }: WithOptionalTx): Promise< Paginated @@ -268,6 +257,7 @@ export class Lessons { after, before, rules, + slots, }); const total = await countRows(baseBuilder.clone(), { @@ -487,6 +477,7 @@ export class Lessons { after, before, rules = [], + slots = [], }: SearchFilter ): Knex.QueryBuilder { //! Because of the one-to-many relationship between the lesson and its @@ -536,6 +527,9 @@ export class Lessons { if (!isEmpty(rules)) builder.whereIn(this.columns.lessons("rule_id"), rules); + if (!isEmpty(slots)) + builder.whereIn(this.columns.lessons("slot_id"), slots); + return builder; } @@ -546,6 +540,7 @@ export class Lessons { duration: row.duration, price: row.price, ruleId: row.rule_id, + slotId: row.slot_id, sessionId: row.session_id, canceledBy: row.canceled_by, canceledAt: row.canceled_at ? row.canceled_at.toISOString() : null, diff --git a/packages/models/tests/availabilitySlot.test.ts b/packages/models/tests/availabilitySlot.test.ts index 031498304..fbbcac275 100644 --- a/packages/models/tests/availabilitySlot.test.ts +++ b/packages/models/tests/availabilitySlot.test.ts @@ -1,5 +1,4 @@ import { availabilitySlots } from "@/availabilitySlots"; -import { knex } from "@/query"; import fixtures from "@fixtures/db"; import { dayjs, nameof, safe } from "@litespace/sol"; import { expect } from "chai"; @@ -57,8 +56,8 @@ describe("AvailabilitySlots", () => { ]); const res = await availabilitySlots.find({ users: [user1.id, user2.id] }); - expect(res).to.have.length(3); - expect(res).to.deep.eq(slots); + expect(res.total).to.eq(3); + expect(res.list).to.deep.eq(slots); }); it("should retieve AvailabilitySlot rows between two dates", async () => { const user = await fixtures.user({}); @@ -86,8 +85,8 @@ describe("AvailabilitySlots", () => { before: slots[1].end, }); - expect(res).to.have.length(2); - expect(res).to.deep.eq(slots.slice(0, 2)); + expect(res.total).to.eq(2); + expect(res.list).to.deep.eq(slots.slice(0, 2)); }); }); @@ -135,7 +134,7 @@ describe("AvailabilitySlots", () => { await availabilitySlots.delete(created.map((slot) => slot.id)); const res = await availabilitySlots.find({ users: [user.id] }); - expect(res).to.have.length(0); + expect(res.total).to.eql(0); }); it("should NOT delete a list of available AvailabilitySlot rows from the database if it has associated lessons/interviews", async () => { @@ -155,12 +154,5 @@ describe("AvailabilitySlots", () => { ); expect(res).to.be.instanceof(Error); }); - - it("should throw an error if the ids list is empty", async () => { - const res = await safe(async () => - knex.transaction(async (tx) => await availabilitySlots.delete([], tx)) - ); - expect(res).to.be.instanceOf(Error); - }); }); }); diff --git a/packages/sol/src/availabilitySlots.ts b/packages/sol/src/availabilitySlots.ts index f432ee8a1..e9a3f86a7 100644 --- a/packages/sol/src/availabilitySlots.ts +++ b/packages/sol/src/availabilitySlots.ts @@ -1,7 +1,7 @@ import { IAvailabilitySlot, IInterview, ILesson } from "@litespace/types"; import { dayjs } from "@/dayjs"; import { flatten, orderBy } from "lodash"; -import { INTERVIEW_DURATION } from "./constants"; +import { INTERVIEW_DURATION } from "@/constants"; /** * Divide list of slots into sub-slots. It is used whenever a user wants to book a lesson diff --git a/packages/types/src/availabilitySlot.ts b/packages/types/src/availabilitySlot.ts index c330d202e..fbe9348ae 100644 --- a/packages/types/src/availabilitySlot.ts +++ b/packages/types/src/availabilitySlot.ts @@ -80,7 +80,7 @@ export type SetAvailabilitySlotsApiPayload = { }; // API Responses -export type FindAvailabilitySlotsApiResponse = Paginated<{ - slots: Self[]; +export type FindAvailabilitySlotsApiResponse = { + slots: Paginated; subslots: SubSlot[]; -}>; +}; diff --git a/packages/types/src/interview.ts b/packages/types/src/interview.ts index a90ae0eae..6dec94c91 100644 --- a/packages/types/src/interview.ts +++ b/packages/types/src/interview.ts @@ -22,6 +22,7 @@ export type Self = { */ interviewee: number; rule: number; + slot: number; session: ISession.Id; }; /** @@ -52,6 +53,7 @@ export type Row = { interviewer_feedback: string | null; interviewee_feedback: string | null; rule_id: number; + slot_id: number; session_id: ISession.Id; note: string | null; level: number | null; @@ -70,6 +72,7 @@ export type CreatePayload = { start: string; session: ISession.Id; rule: number; + slot: number; interviewer: number; interviewee: number; }; @@ -81,6 +84,7 @@ export type CreateApiPayload = { */ start: string; ruleId: number; + slotId: number; }; export type CreateInterviewApiResponse = Self; diff --git a/packages/types/src/lesson.ts b/packages/types/src/lesson.ts index e3059c87e..8947c9153 100644 --- a/packages/types/src/lesson.ts +++ b/packages/types/src/lesson.ts @@ -6,6 +6,7 @@ export type Row = { duration: number; price: number; rule_id: number; + slot_id: number; session_id: ISession.Id; canceled_by: number | null; canceled_at: Date | null; @@ -30,6 +31,7 @@ export type Self = { */ price: number; ruleId: number; + slotId: number; sessionId: ISession.Id; /** * ID of the member who canceled the lesson. @@ -91,6 +93,7 @@ export type CreatePayload = { tutor: number; student: number; rule: number; + slot: number; session: ISession.Id; /** * Lesson price scaled to the power of 2. @@ -101,6 +104,7 @@ export type CreatePayload = { export type CreateApiPayload = { tutorId: number; ruleId: number; + slotId: number; start: string; duration: Duration; }; diff --git a/services/server/fixtures/db.ts b/services/server/fixtures/db.ts index ac6ec73ed..30c66eab2 100644 --- a/services/server/fixtures/db.ts +++ b/services/server/fixtures/db.ts @@ -10,6 +10,7 @@ import { users, ratings, tutors, + availabilitySlots, } from "@litespace/models"; import { IInterview, @@ -20,9 +21,10 @@ import { IRating, IMessage, ISession, + IAvailabilitySlot, } from "@litespace/types"; import { faker } from "@faker-js/faker/locale/ar"; -import { entries, range, sample } from "lodash"; +import { entries, first, range, sample } from "lodash"; import { Knex } from "knex"; import { Time } from "@litespace/sol/time"; export { faker } from "@faker-js/faker/locale/ar"; @@ -46,6 +48,7 @@ export async function flush() { await rules.builder(tx).del(); await ratings.builder(tx).del(); await tutors.builder(tx).del(); + await availabilitySlots.builder(tx).del(); await users.builder(tx).del(); }); } @@ -108,15 +111,16 @@ export async function slot(payload?: Partial) { ? 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]; + const newSlots = await availabilitySlots.create([ + { + userId: payload?.userId || 1, + start: start.toISOString(), + end: end.toISOString(), + }, + ]); + const res = first(newSlots); + if (!res) throw Error("error: couldn't insert new slot."); + return res; } async function activatedRule(payload?: Partial) { @@ -149,8 +153,8 @@ const or = { if (!roomId) return await makeRoom(); return roomId; }, - async slotId(id?: number): Promise { - if (!id) return await slot().then((slot) => slot.id); + async slotId(id?: number, userId?: number): Promise { + if (!id) return await slot({ userId }).then((slot) => slot.id); return id; }, start(start?: string): string { @@ -184,14 +188,18 @@ export async function lesson( duration: payload?.duration || sample([15, 30]), price: payload?.price || faker.number.int(500), rule: await or.ruleId(payload?.rule, payload?.tutor), - slot: await or.slotId(payload?.slot), + slot: await or.slotId(payload?.slot, payload?.tutor), student, tutor, tx, }); if (payload?.canceled) - await lessons.cancel({ canceledBy: tutor, id: data.lesson.id, tx }); + await lessons.cancel({ + canceledBy: tutor, + ids: [data.lesson.id], + tx, + }); return data; }); @@ -203,7 +211,7 @@ export async function interview(payload: Partial) { interviewee: await or.tutorId(payload.interviewee), session: await or.sessionId("interview"), rule: await or.ruleId(payload.rule, payload.interviewer), - slot: await or.slotId(payload.slot), + slot: await or.slotId(payload.slot, payload.interviewer), start: or.start(payload.start), }); } @@ -342,7 +350,10 @@ async function makeLessons({ range(0, canceledFutureLessonCount).map(async (i) => { const info = futureLessons[i]; if (!lesson) throw new Error("invalid future lesson index"); - await lessons.cancel({ canceledBy: tutor, id: info.lesson.id }); + await lessons.cancel({ + canceledBy: tutor, + ids: [info.lesson.id], + }); return info; }) ); @@ -352,7 +363,10 @@ async function makeLessons({ range(0, canceledPastLessonCount).map(async (i) => { const info = pastLessons[i]; if (!info) throw new Error("invalid past lesson index"); - await lessons.cancel({ canceledBy: tutor, id: info.lesson.id }); + await lessons.cancel({ + canceledBy: tutor, + ids: [info.lesson.id], + }); return info; }) ); @@ -438,7 +452,7 @@ async function makeRoom(payload?: [number, number]) { } async function makeMessage( - tx: Knex.Transaction, + _: Knex.Transaction, payload?: Partial ) { const roomId: number = await or.roomId(payload?.roomId); @@ -503,6 +517,7 @@ export default { flush, topic, rule, + slot, room: makeRoom, message: makeMessage, make: { diff --git a/services/server/src/constants.ts b/services/server/src/constants.ts index 86725b7a7..ec9150a2f 100644 --- a/services/server/src/constants.ts +++ b/services/server/src/constants.ts @@ -116,3 +116,5 @@ export const platformConfig = { */ interviewDuration: 30, }; + +export const MAX_FULL_FLAG_DAYS = 14; diff --git a/services/server/src/handlers/availabilitySlot.ts b/services/server/src/handlers/availabilitySlot.ts index 39e7815a5..61e8863c2 100644 --- a/services/server/src/handlers/availabilitySlot.ts +++ b/services/server/src/handlers/availabilitySlot.ts @@ -2,25 +2,20 @@ import { bad, conflict, forbidden, notfound } from "@/lib/error"; import { datetime, id, skippablePagination } from "@/validation/utils"; import { isTutor, isTutorManager, isUser } from "@litespace/auth"; import { IAvailabilitySlot } from "@litespace/types"; -import { - availabilitySlots, - interviews, - knex, - lessons, -} from "@litespace/models"; +import { availabilitySlots, knex } from "@litespace/models"; import dayjs from "@/lib/dayjs"; import { NextFunction, Request, Response } from "express"; import safeRequest from "express-async-handler"; -import { asSubSlots, isIntersecting } from "@litespace/sol"; +import { isIntersecting } from "@litespace/sol"; import zod from "zod"; import { isEmpty } from "lodash"; -import { deleteSlots } from "@/lib/availabilitySlot"; +import { deleteSlots, getSubslots } from "@/lib/availabilitySlot"; +import { MAX_FULL_FLAG_DAYS } from "@/constants"; const findPayload = zod.object({ userId: id, after: datetime, before: datetime, - slotsOnly: zod.boolean().optional().default(false), pagination: skippablePagination.optional(), }); @@ -50,61 +45,40 @@ async function find(req: Request, res: Response, next: NextFunction) { const user = req.user; if (!isUser(user)) return next(forbidden()); - const { userId, after, before, slotsOnly, pagination } = findPayload.parse( - req.query - ); + const { userId, after, before, pagination } = findPayload.parse(req.query); const diff = dayjs(before).diff(after, "days"); if (diff < 0) return next(bad()); + const canUseFullFlag = - after && before && dayjs.utc(before).diff(after, "days") <= 30; + after && + before && + dayjs.utc(before).diff(after, "days") <= MAX_FULL_FLAG_DAYS; + + if (pagination?.full && !canUseFullFlag) return next(bad()); const paginatedSlots = await availabilitySlots.find({ users: [userId], after, before, - pagination: { - page: pagination?.page || 1, - size: pagination?.size || 10, - full: pagination?.full || !!canUseFullFlag, - }, + pagination, }); - // return only-slots only if the user is a tutor - if (slotsOnly && (isTutor(user.role) || isTutorManager(user.role))) { - const result = { - list: [{ slots: paginatedSlots.list, subslots: [] }], - total: paginatedSlots.total, - }; - res.status(200).json(result); - return; - } - + // NOTE: return only-slots only if the user is a tutor const slotIds = paginatedSlots.list.map((slot) => slot.id); - - const paginatedLessons = await lessons.find({ - users: [userId], - slots: slotIds, - after, - before, - }); - - const userInterviews = await interviews.find({ - users: [userId], - slots: slotIds, - }); + const subslots = + isTutor(user) || isTutorManager(user) + ? [] + : await getSubslots({ + slotIds, + userId, + after, + before, + }); const result: IAvailabilitySlot.FindAvailabilitySlotsApiResponse = { - list: [ - { - slots: paginatedSlots.list, - subslots: asSubSlots([ - ...paginatedLessons.list, - ...userInterviews.list, - ]), - }, - ], - total: paginatedSlots.total, + slots: paginatedSlots, + subslots, }; res.status(200).json(result); @@ -146,6 +120,39 @@ 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, + }); + + 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()); + } + // delete slots await deleteSlots({ currentUserId: user.id, @@ -165,26 +172,8 @@ async function set(req: Request, res: Response, next: NextFunction) { ); } - if (isEmpty(creates)) return; - - // check for confliction in creates list - const mySlots = await availabilitySlots.find({ - users: [user.id], - deleted: false, - }); - const intersecting = creates.find((create) => - isIntersecting( - { - id: 0, - start: create.start, - end: create.end, - }, - mySlots.list - ) - ); - if (intersecting) return conflict(); - // create slots + if (isEmpty(creates)) return; await availabilitySlots.create( creates.map(({ start, end }) => ({ userId: user.id, diff --git a/services/server/src/handlers/interview.ts b/services/server/src/handlers/interview.ts index b9da97937..c84ffa106 100644 --- a/services/server/src/handlers/interview.ts +++ b/services/server/src/handlers/interview.ts @@ -45,6 +45,7 @@ const createInterviewPayload = zod.object({ interviewerId: id, start: datetime, ruleId: id, + slotId: id, }); const updateInterviewPayload = zod.object({ @@ -80,7 +81,7 @@ async function createInterview( if (!allowed) return next(forbidden()); const intervieweeId = user.id; - const { interviewerId, start, ruleId }: IInterview.CreateApiPayload = + const { interviewerId, start, ruleId, slotId }: IInterview.CreateApiPayload = createInterviewPayload.parse(req.body); const interviewer = await users.findById(interviewerId); @@ -94,6 +95,9 @@ async function createInterview( const rule = await rules.findById(ruleId); if (!rule) return next(notfound.rule()); + const slot = await availabilitySlots.findById(slotId); + if (!slot) return next(notfound.slot()); + // Find rule interviews to check if the incoming interview is contradicting with existing ones. const ruleInterviews = await interviews.findByRuleId(rule.id); @@ -115,6 +119,7 @@ async function createInterview( interviewee: intervieweeId, session: genSessionId("interview"), rule: rule.id, + slot: slot.id, start, tx, }); diff --git a/services/server/src/handlers/lesson.ts b/services/server/src/handlers/lesson.ts index 9e183ac0b..11bc99437 100644 --- a/services/server/src/handlers/lesson.ts +++ b/services/server/src/handlers/lesson.ts @@ -19,7 +19,7 @@ import { calculateLessonPrice } from "@litespace/sol/lesson"; import { safe } from "@litespace/sol/error"; import { unpackRules } from "@litespace/sol/rule"; import { isAdmin, isStudent, isUser } from "@litespace/auth"; -import { platformConfig } from "@/constants"; +import { MAX_FULL_FLAG_DAYS, platformConfig } from "@/constants"; import dayjs from "@/lib/dayjs"; import { canBook } from "@/lib/session"; import { concat, isEmpty, isEqual } from "lodash"; @@ -28,6 +28,7 @@ import { genSessionId } from "@litespace/sol"; const createLessonPayload = zod.object({ tutorId: id, ruleId: id, + slotId: id, start: datetime, duration, }); @@ -92,6 +93,7 @@ function create(context: ApiContext) { start: payload.start, duration: payload.duration, rule: payload.ruleId, + slot: payload.slotId, session: genSessionId("lesson"), price, tx, @@ -155,7 +157,7 @@ async function findLessons(req: Request, res: Response, next: NextFunction) { const canUseFullFlag = query.after && query.before && - dayjs.utc(query.before).diff(query.after, "days") <= 14; + dayjs.utc(query.before).diff(query.after, "days") <= MAX_FULL_FLAG_DAYS; if (query.full && !canUseFullFlag) return next(bad()); @@ -229,7 +231,7 @@ function cancel(context: ApiContext) { await lessons.cancel({ canceledBy: user.id, - id: lessonId, + ids: [lessonId], }); res.status(200).send(); diff --git a/services/server/src/index.ts b/services/server/src/index.ts index dfd01d8fe..0067f6052 100644 --- a/services/server/src/index.ts +++ b/services/server/src/index.ts @@ -83,6 +83,7 @@ app.use("/api/v1/user", routes.user(context)); app.use("/api/v1/rule", routes.rule(context)); app.use("/api/v1/lesson", routes.lesson(context)); app.use("/api/v1/interview", routes.interview); +app.use("/api/v1/availability-slot", routes.availabilitySlot); app.use("/api/v1/rating", routes.rating); app.use("/api/v1/chat", routes.chat); app.use("/api/v1/plan", routes.plan); diff --git a/services/server/src/lib/availabilitySlot.ts b/services/server/src/lib/availabilitySlot.ts index 92ce960c4..556059e96 100644 --- a/services/server/src/lib/availabilitySlot.ts +++ b/services/server/src/lib/availabilitySlot.ts @@ -1,4 +1,6 @@ import { availabilitySlots, interviews, lessons } from "@litespace/models"; +import { asSubSlots } from "@litespace/sol"; +import { IAvailabilitySlot } from "@litespace/types"; import { Knex } from "knex"; import { isEmpty } from "lodash"; @@ -9,7 +11,7 @@ export async function deleteSlots({ }: { currentUserId: number; ids: number[]; - tx?: Knex.Transaction; + tx: Knex.Transaction; }): Promise { if (isEmpty(ids)) return; @@ -19,15 +21,45 @@ export async function deleteSlots({ if (associatesCount <= 0) return await availabilitySlots.delete(ids, tx); - await lessons.cancelBatch({ - ids: associatedLessons.list.map((l) => l.id), - canceledBy: currentUserId, - tx, + await Promise.all([ + lessons.cancel({ + ids: associatedLessons.list.map((l) => l.id), + canceledBy: currentUserId, + tx, + }), + + interviews.cancel({ + ids: associatedInterviews.list.map((i) => i.ids.self), + canceledBy: currentUserId, + tx, + }), + + availabilitySlots.markAsDeleted({ ids: ids, tx }), + ]); +} + +export async function getSubslots({ + slotIds, + userId, + after, + before, +}: { + slotIds: number[]; + userId: number; + after: string; + before: string; +}): Promise { + const paginatedLessons = await lessons.find({ + users: [userId], + slots: slotIds, + after, + before, }); - await interviews.cancel({ - ids: associatedInterviews.list.map((i) => i.ids.self), - canceledBy: currentUserId, - tx, + + const paginatedInterviews = await interviews.find({ + users: [userId], + slots: slotIds, }); - await availabilitySlots.markAsDeleted({ ids: ids, tx }); + + return asSubSlots([...paginatedLessons.list, ...paginatedInterviews.list]); } diff --git a/services/server/src/routes/index.ts b/services/server/src/routes/index.ts index 7590e778d..381a6cfdc 100644 --- a/services/server/src/routes/index.ts +++ b/services/server/src/routes/index.ts @@ -1,5 +1,6 @@ import user from "@/routes/user"; import interview from "@/routes/interview"; +import availabilitySlot from "@/routes/availabilitySlot"; import rating from "@/routes/rating"; import chat from "@/routes/chat"; import plan from "@/routes/plan"; diff --git a/services/server/tests/api/chat.test.ts b/services/server/tests/api/chat.test.ts index 7ccb243b7..09c8a5cbb 100644 --- a/services/server/tests/api/chat.test.ts +++ b/services/server/tests/api/chat.test.ts @@ -1,4 +1,3 @@ -import { flush } from "@fixtures/shared"; import { Api, unexpectedApiSuccess } from "@fixtures/api"; import { expect } from "chai"; import db from "@fixtures/db"; @@ -9,7 +8,7 @@ import { Wss } from "@litespace/types"; describe("/api/v1/chat", () => { beforeEach(async () => { - await flush(); + await db.flush(); }); describe("PUT /api/v1/chat/room/:roomId", () => { diff --git a/services/server/tests/api/lesson.test.ts b/services/server/tests/api/lesson.test.ts index 92b2b2a60..052f14977 100644 --- a/services/server/tests/api/lesson.test.ts +++ b/services/server/tests/api/lesson.test.ts @@ -59,6 +59,7 @@ describe("/api/v1/lesson/", () => { await studentApi.atlas.lesson.create({ start: rule.start, ruleId: rule.id, + slotId: slot.id, duration: 30, tutorId: tutor.user.id, }); @@ -125,11 +126,18 @@ describe("/api/v1/lesson/", () => { end: dayjs.utc().add(10, "days").toISOString(), }); + const slot = await db.slot({ + userId: tutor.id, + start: dayjs.utc().startOf("day").toISOString(), + end: dayjs.utc().add(10, "days").toISOString(), + }); + const lesson = await db.lesson({ tutor: tutor.id, student: student.id, timing: "future", rule: rule.id, + slot: slot.id, }); const studentApi = await Api.forStudent(); @@ -151,11 +159,18 @@ describe("/api/v1/lesson/", () => { end: dayjs.utc().add(10, "days").toISOString(), }); + const slot = await db.slot({ + userId: tutor.id, + start: dayjs.utc().startOf("day").toISOString(), + end: dayjs.utc().add(10, "days").toISOString(), + }); + const lesson = await db.lesson({ tutor: tutor.id, student: student.user.id, timing: "future", rule: rule.id, + slot: slot.id, }); const res = await safe(async () => diff --git a/services/server/tests/api/rule.test.ts b/services/server/tests/api/rule.test.ts index cb778dfb7..a1fadf58e 100644 --- a/services/server/tests/api/rule.test.ts +++ b/services/server/tests/api/rule.test.ts @@ -8,7 +8,7 @@ import { expect } from "chai"; import dayjs from "dayjs"; import { first } from "lodash"; -describe("/api/v1/rule/", () => { +describe.skip("/api/v1/rule/", () => { beforeEach(async () => { await flush(); }); diff --git a/services/server/tests/api/slot.test.ts b/services/server/tests/api/slot.test.ts index 0d563ad87..c2928d041 100644 --- a/services/server/tests/api/slot.test.ts +++ b/services/server/tests/api/slot.test.ts @@ -58,16 +58,15 @@ describe("/api/v1/availability-slot/", () => { const now = dayjs.utc(); const mock = await genMockData(tutor.id, now); - const res = await studentApi.atlas.availabilitySlot.find({ + const { slots, subslots } = await studentApi.atlas.availabilitySlot.find({ userId: tutor.id, after: now.toISOString(), before: now.add(2, "days").toISOString(), pagination: {}, }); - const { slots, subslots } = res.list[0]; - expect(res.total).to.eq(2); - expect(slots).to.deep.eq(mock.slots); + expect(slots.total).to.eq(2); + expect(slots.list).to.deep.eq(mock.slots); expect(subslots).to.have.length( mock.lessons.length + mock.interviews.length ); @@ -119,7 +118,7 @@ describe("/api/v1/availability-slot/", () => { slots: [ { action: "create", - start: now.toISOString(), + start: now.add(1, "hour").toISOString(), end: now.add(6, "hours").toISOString(), }, ], @@ -130,7 +129,7 @@ describe("/api/v1/availability-slot/", () => { const c1 = { userId: tutor.user.id, - start: now.toISOString(), + start: now.add(1, "hour").toISOString(), end: now.add(6, "hours").toISOString(), }; const c2 = { @@ -152,7 +151,7 @@ describe("/api/v1/availability-slot/", () => { slots: [ { action: "create", - start: now.toISOString(), + start: now.add(1, "hour").toISOString(), end: now.add(6, "hours").toISOString(), }, ], @@ -162,6 +161,44 @@ describe("/api/v1/availability-slot/", () => { expect(res).to.deep.eq(conflict()); }); + it("should respond with bad request if the slot is not in the future", async () => { + const tutorApi = await Api.forTutor(); + const now = dayjs.utc(); + + const res = await safe(async () => + tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "create", + start: now.subtract(1, "hour").toISOString(), + end: now.add(6, "hours").toISOString(), + }, + ], + }) + ); + + expect(res).to.deep.eq(bad()); + }); + + it("should respond with bad request if the slot is not well structured", async () => { + const tutorApi = await Api.forTutor(); + const now = dayjs.utc(); + + const res = await safe(async () => + tutorApi.atlas.availabilitySlot.set({ + slots: [ + { + action: "create", + start: now.add(4, "hours").toISOString(), + end: now.add(2, "hours").toISOString(), + }, + ], + }) + ); + + expect(res).to.deep.eq(bad()); + }); + it("should successfully update an existing slot", async () => { const tutorApi = await Api.forTutor(); const tutor = await tutorApi.findCurrentUser(); diff --git a/services/server/tests/api/user.test.ts b/services/server/tests/api/user.test.ts index b9aa906ca..75d6c063c 100644 --- a/services/server/tests/api/user.test.ts +++ b/services/server/tests/api/user.test.ts @@ -1,4 +1,3 @@ -import { flush } from "@fixtures/shared"; import { IUser } from "@litespace/types"; import { Api } from "@fixtures/api"; import db, { faker } from "@fixtures/db"; @@ -7,14 +6,14 @@ import { safe } from "@litespace/sol/error"; import { cacheTutors } from "@/lib/tutor"; import dayjs from "@/lib/dayjs"; import { cache } from "@/lib/cache"; -import { knex, tutors, users } from "@litespace/models"; +import { tutors, users } from "@litespace/models"; import { Role } from "@litespace/types/dist/esm/user"; import { first, range } from "lodash"; import { forbidden, notfound } from "@/lib/error"; describe("/api/v1/user/", () => { beforeEach(async () => { - await flush(); + await db.flush(); }); describe("POST /api/v1/user", () => { @@ -82,7 +81,7 @@ describe("/api/v1/user/", () => { }); beforeEach(async () => { - await flush(); + await db.flush(); await cache.flush(); }); @@ -251,7 +250,7 @@ describe("/api/v1/user/", () => { describe("GET /api/v1/user/tutor/list/uncontacted", () => { beforeEach(async () => { - await flush(); + await db.flush(); }); it("should successfully retrieve list of tutors with which the student has not chat room yet.", async () => { @@ -288,7 +287,7 @@ describe("/api/v1/user/", () => { }); beforeEach(async () => { - await flush(); + await db.flush(); await cache.flush(); }); @@ -365,7 +364,7 @@ describe("/api/v1/user/", () => { describe("GET /api/v1/user/tutor/stats/personalized", () => { beforeEach(async () => { - await flush(); + await db.flush(); }); it("should retrieve tutor stats by current logged-in user id.", async () => { @@ -445,13 +444,17 @@ describe("/api/v1/user/", () => { describe("GET /api/v1/user/student/stats/personalized", () => { beforeEach(async () => { - await flush(); + await db.flush(); }); it("should retrieve student stats by current logged-in user id.", async () => { const studentApi = await Api.forStudent(); const student = await studentApi.findCurrentUser(); + const tutor1 = await db.tutor(); + const tutor2 = await db.tutor(); + const tutor3 = await db.tutor(); + const rule1 = await db.rule({ userId: student.user.id, start: dayjs.utc().subtract(2, "days").toISOString(), @@ -467,17 +470,20 @@ describe("/api/v1/user/", () => { const lesson1 = await db.lesson({ student: student.user.id, + tutor: tutor1.id, rule: rule1.id, start: rule1.start, }); await db.lesson({ student: student.user.id, + tutor: tutor2.id, rule: rule2.id, }); await db.lesson({ student: student.user.id, + tutor: tutor3.id, rule: rule3.id, canceled: true, }); diff --git a/services/server/tests/wss/session.test.ts b/services/server/tests/wss/session.test.ts index 189fb7dbc..4c9a0eac8 100644 --- a/services/server/tests/wss/session.test.ts +++ b/services/server/tests/wss/session.test.ts @@ -1,5 +1,5 @@ import { Api } from "@fixtures/api"; -import { flush } from "@fixtures/db"; +import db from "@fixtures/db"; import { ClientSocket } from "@fixtures/wss"; import { IUser, Wss } from "@litespace/types"; import dayjs from "@/lib/dayjs"; @@ -26,7 +26,7 @@ describe("sessions test suite", () => { }); beforeEach(async () => { - await flush(); + await db.flush(); await cache.flush(); tutorApi = await Api.forTutor(); @@ -126,6 +126,7 @@ describe("sessions test suite", () => { start: selectedRuleEvent.start, duration: 30, ruleId: rule.id, + slotId: slot.id, tutorId: tutor.user.id, }); @@ -183,6 +184,7 @@ describe("sessions test suite", () => { start: selectedRuleEvent.start, duration: 30, ruleId: rule.id, + slotId: slot.id, tutorId: tutor.user.id, }); From 8d1ebd7cd066ab313fc23bd4f24b04bc72dbbd51 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 15 Jan 2025 07:12:06 +0200 Subject: [PATCH 3/3] feat(server): validate availability slots --- packages/models/src/availabilitySlots.ts | 28 +++- .../models/tests/availabilitySlot.test.ts | 9 +- packages/sol/src/availabilitySlots.ts | 8 +- packages/sol/tests/availabilitySlot.test.ts | 3 + packages/types/src/availabilitySlot.ts | 9 +- .../server/src/handlers/availabilitySlot.ts | 83 ++++++------ services/server/src/lib/availabilitySlot.ts | 123 ++++++++++++++++-- services/server/tests/api/slot.test.ts | 8 +- 8 files changed, 194 insertions(+), 77 deletions(-) diff --git a/packages/models/src/availabilitySlots.ts b/packages/models/src/availabilitySlots.ts index 344d1d53f..b7f138695 100644 --- a/packages/models/src/availabilitySlots.ts +++ b/packages/models/src/availabilitySlots.ts @@ -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. */ @@ -30,7 +34,6 @@ type SearchFilter = { */ before?: string; deleted?: boolean; - pagination?: IFilter.SkippablePagination; }; export class AvailabilitySlots { @@ -99,16 +102,28 @@ export class AvailabilitySlots { slots, after, before, - pagination, - }: WithOptionalTx): Promise> { + page, + size, + full, + deleted, + execludeSlots, + }: WithOptionalTx): Promise< + Paginated + > { 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, @@ -153,10 +168,13 @@ export class AvailabilitySlots { applySearchFilter( builder: Knex.QueryBuilder, - { slots, users, after, before, deleted }: SearchFilter + { slots, users, after, before, deleted, execludeSlots }: SearchFilter ): Knex.QueryBuilder { 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); diff --git a/packages/models/tests/availabilitySlot.test.ts b/packages/models/tests/availabilitySlot.test.ts index fbbcac275..814ac5b5d 100644 --- a/packages/models/tests/availabilitySlot.test.ts +++ b/packages/models/tests/availabilitySlot.test.ts @@ -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); }); }); diff --git a/packages/sol/src/availabilitySlots.ts b/packages/sol/src/availabilitySlots.ts index e9a3f86a7..f22241c92 100644 --- a/packages/sol/src/availabilitySlots.ts +++ b/packages/sol/src/availabilitySlots.ts @@ -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 = diff --git a/packages/sol/tests/availabilitySlot.test.ts b/packages/sol/tests/availabilitySlot.test.ts index ca308fed2..a2b8cd89e 100644 --- a/packages/sol/tests/availabilitySlot.test.ts +++ b/packages/sol/tests/availabilitySlot.test.ts @@ -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(), diff --git a/packages/types/src/availabilitySlot.ts b/packages/types/src/availabilitySlot.ts index fbe9348ae..86b08e534 100644 --- a/packages/types/src/availabilitySlot.ts +++ b/packages/types/src/availabilitySlot.ts @@ -45,15 +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 = { +export type FindAvailabilitySlotsApiQuery = SkippablePagination & { userId: number; after?: string; before?: string; -} & { - pagination?: SkippablePagination; }; export type CreateAction = { diff --git a/services/server/src/handlers/availabilitySlot.ts b/services/server/src/handlers/availabilitySlot.ts index 61e8863c2..58cc70306 100644 --- a/services/server/src/handlers/availabilitySlot.ts +++ b/services/server/src/handlers/availabilitySlot.ts @@ -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({ @@ -45,7 +56,16 @@ 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, + }: IAvailabilitySlot.FindAvailabilitySlotsApiQuery = findPayload.parse( + req.query + ); const diff = dayjs(before).diff(after, "days"); if (diff < 0) return next(bad()); @@ -55,13 +75,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 @@ -97,13 +119,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; const updates = payload.slots.filter( - (obj) => obj.action === "update" + (slot) => slot.action === "update" ) as Array; const deletes = payload.slots.filter( - (obj) => obj.action === "delete" + (slot) => slot.action === "delete" ) as Array; const error = await knex.transaction(async (tx) => { @@ -120,38 +142,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({ diff --git a/services/server/src/lib/availabilitySlot.ts b/services/server/src/lib/availabilitySlot.ts index 556059e96..bef1d4773 100644 --- a/services/server/src/lib/availabilitySlot.ts +++ b/services/server/src/lib/availabilitySlot.ts @@ -1,8 +1,9 @@ import { availabilitySlots, interviews, lessons } from "@litespace/models"; -import { asSubSlots } from "@litespace/sol"; +import { asSubSlots, isIntersecting } from "@litespace/sol"; import { IAvailabilitySlot } from "@litespace/types"; import { Knex } from "knex"; -import { isEmpty } from "lodash"; +import { isEmpty, uniqBy } from "lodash"; +import dayjs from "@/lib/dayjs"; export async function deleteSlots({ currentUserId, @@ -14,26 +15,38 @@ export async function deleteSlots({ tx: Knex.Transaction; }): Promise { if (isEmpty(ids)) return; + const now = dayjs.utc(); + /** + * Get "all (skip the pagination)" lessons that are not canceled and still in + * the future (didn't happen yet). + */ + const associatedLessons = await lessons.find({ + after: now.toISOString(), + canceled: false, + full: true, + slots: ids, + tx, + }); + // TODO: extend `interviews.find` with `after`, `before`, and the `full` params. + const associatedInterviews = await interviews.find({ + slots: ids, + tx, + }); - const associatedLessons = await lessons.find({ slots: ids, tx }); - const associatedInterviews = await interviews.find({ slots: ids, tx }); const associatesCount = associatedLessons.total + associatedInterviews.total; - if (associatesCount <= 0) return await availabilitySlots.delete(ids, tx); await Promise.all([ lessons.cancel({ - ids: associatedLessons.list.map((l) => l.id), + ids: associatedLessons.list.map((lesson) => lesson.id), canceledBy: currentUserId, tx, }), - interviews.cancel({ - ids: associatedInterviews.list.map((i) => i.ids.self), + ids: associatedInterviews.list.map((lesson) => lesson.ids.self), canceledBy: currentUserId, tx, }), - availabilitySlots.markAsDeleted({ ids: ids, tx }), ]); } @@ -46,8 +59,8 @@ export async function getSubslots({ }: { slotIds: number[]; userId: number; - after: string; - before: string; + after?: string; + before?: string; }): Promise { const paginatedLessons = await lessons.find({ users: [userId], @@ -63,3 +76,91 @@ export async function getSubslots({ return asSubSlots([...paginatedLessons.list, ...paginatedInterviews.list]); } + +function updateSlots( + slots: IAvailabilitySlot.Self[], + updates: IAvailabilitySlot.UpdateAction[] +) { + return structuredClone(slots).map((slot) => { + const update = updates.find((action) => action.id === slot.id); + if (update?.start) slot.start = update.start; + if (update?.end) slot.end = update.end; + return slot; + }); +} + +// TODO: unit tests are needed to test this function +export async function isConflictingSlots({ + userId, + creates, + updates, + deletes, +}: { + userId: number; + creates: IAvailabilitySlot.CreateAction[]; + updates: IAvailabilitySlot.UpdateAction[]; + deletes: IAvailabilitySlot.DeleteAction[]; +}): Promise { + const now = dayjs.utc(); + // Find "all" (hence `full=true`) user slots that are not deleted or about to + // be deleted (hence why `execludeSlots`) + // TODO: fetch only future slots @mmoehabb & @neuodev + const userSlots = await availabilitySlots.find({ + users: [userId], + deleted: false, + execludeSlots: deletes.map((action) => action.id), + full: true, + }); + + const updateIds = updates.map((action) => action.id); + // Filter out the slots that are about to be updated + const updatableSlots = userSlots.list.filter((slot) => + updateIds.includes(slot.id) + ); + // Filter out the slots that are "ready only" + const readOnlySlots = userSlots.list.filter( + (slot) => !updateIds.includes(slot.id) + ); + // Create a version of the updatable slots with the desired updates. + const updatedSlots = updateSlots(updatableSlots, updates); + + // validate the slots that are about to be created and the slots with the + // applied updates. + for (const slot of [...creates, ...updatedSlots]) { + const start = dayjs.utc(slot.start); + const end = dayjs.utc(slot.end); + if (start.isAfter(end) || start.isSame(end) || start.isBefore(now)) + return true; + } + + // List of the final version of all user slots + // 1. `readOnlySlots` -> all user slots the are not deleted or about to be + // updated. + // 2. `creates` -> user slots that are about to be inserted in the to + // database. + // 3. `updatedSlots` -> in-memory slots with the desired updates (updates are + // applied) + const allSlots = [...readOnlySlots, ...creates, ...updatedSlots]; + + // All start and end dates must be unique. + const starts = uniqBy(allSlots, (slot) => slot.start); + const ends = uniqBy(allSlots, (slot) => slot.end); + if (starts.length !== allSlots.length || ends.length !== allSlots.length) + return true; + + // Rule: each slot should not conflict with the other slots. + for (const currentSlot of allSlots) { + const otherSlots = allSlots.filter( + (otherSlot) => otherSlot.start !== currentSlot.start + ); + + const intersecting = isIntersecting( + { start: currentSlot.start, end: currentSlot.end }, + otherSlots + ); + + if (intersecting) return true; + } + + return false; +} diff --git a/services/server/tests/api/slot.test.ts b/services/server/tests/api/slot.test.ts index c2928d041..deaeeca49 100644 --- a/services/server/tests/api/slot.test.ts +++ b/services/server/tests/api/slot.test.ts @@ -62,7 +62,6 @@ describe("/api/v1/availability-slot/", () => { userId: tutor.id, after: now.toISOString(), before: now.add(2, "days").toISOString(), - pagination: {}, }); expect(slots.total).to.eq(2); @@ -75,15 +74,12 @@ describe("/api/v1/availability-slot/", () => { it("should respond with bad request if the `after` date is before the `before` date", async () => { const studentApi = await Api.forStudent(); const tutor = await db.tutor(); - const now = dayjs.utc(); - const res = await safe(async () => studentApi.atlas.availabilitySlot.find({ userId: tutor.id, after: now.add(2, "days").toISOString(), before: now.toISOString(), - pagination: {}, }) ); @@ -177,7 +173,7 @@ describe("/api/v1/availability-slot/", () => { }) ); - expect(res).to.deep.eq(bad()); + expect(res).to.deep.eq(conflict()); }); it("should respond with bad request if the slot is not well structured", async () => { @@ -196,7 +192,7 @@ describe("/api/v1/availability-slot/", () => { }) ); - expect(res).to.deep.eq(bad()); + expect(res).to.deep.eq(conflict()); }); it("should successfully update an existing slot", async () => {