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/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/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/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/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..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, 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"; -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(); }); } @@ -102,6 +103,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 +144,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; @@ -158,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; }); @@ -176,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), }); } @@ -292,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; }) ); @@ -302,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; }) ); @@ -444,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 b40a0d8d0..431299a9b 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,11 @@ exports.up = (pgm) => { duration: { type: "SMALLINT", notNull: true }, price: { type: "INT", notNull: true }, rule_id: { type: "SERIAL", references: "rules(id)", notNull: true }, + slot_id: { + type: "SERIAL", + references: "availability_slots(id)", + notNull: true, + }, 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 +131,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..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,6 +286,15 @@ async function main(): Promise { monthday: sample(range(1, 31)), }); + // 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(), + })) + ); + const times = range(0, 24).map((hour) => [hour.toString().padStart(2, "0"), "00"].join(":") ); @@ -367,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, }); @@ -402,6 +413,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({ @@ -410,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 51% rename from packages/models/src/availabilitySlot.ts rename to packages/models/src/availabilitySlots.ts index 603a32d35..b7f138695 100644 --- a/packages/models/src/availabilitySlot.ts +++ b/packages/models/src/availabilitySlots.ts @@ -1,10 +1,24 @@ -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"; -import { knex, column, WithOptionalTx } from "@/query"; +import { + knex, + column, + countRows, + WithOptionalTx, + withSkippablePagination, +} from "@/query"; 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. */ @@ -19,6 +33,7 @@ type SearchFilter = { * All slots before (or the same as) this date will be included. */ before?: string; + deleted?: boolean; }; export class AvailabilitySlots { @@ -47,58 +62,119 @@ 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({ tx, users, + slots, after, before, - }: WithOptionalTx): Promise { + page, + size, + full, + deleted, + execludeSlots, + }: WithOptionalTx): Promise< + Paginated + > { const baseBuilder = this.applySearchFilter(this.builder(tx), { users, + slots, after, before, + deleted, + execludeSlots, }); - 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(), { + page, + size, + full, + }); + 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; + } + + 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; + } + + /** + * 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) + .where(this.column("deleted"), false) + .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, 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); @@ -107,6 +183,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 +192,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/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 4aadad6af..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, }) @@ -82,6 +84,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] @@ -120,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, @@ -129,6 +153,7 @@ export class Interviews { page, size, rules = [], + slots = [], cancelled, tx, }: { @@ -139,7 +164,12 @@ export class Interviews { signed?: boolean; signers?: number[]; rules?: number[]; + /** + * slots ids to be included in the query result + */ + slots?: number[]; cancelled?: boolean; + pagination?: IFilter.SkippablePagination; } & IFilter.Pagination): Promise> { const baseBuilder = this.builder(tx); @@ -166,6 +196,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) @@ -188,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 5e79455fa..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, @@ -135,10 +141,10 @@ export class Lessons { async cancel({ canceledBy, - id, + ids, tx, }: WithOptionalTx<{ - id: number; + ids: number[]; canceledBy: number; }>): Promise { const now = dayjs.utc().toDate(); @@ -148,7 +154,7 @@ export class Lessons { canceled_at: now, updated_at: now, }) - .where(this.columns.lessons("id"), id); + .whereIn(this.columns.lessons("id"), ids); } private async findOneBy( @@ -236,6 +242,7 @@ export class Lessons { after, before, rules, + slots, ...pagination }: WithOptionalTx): Promise< Paginated @@ -250,6 +257,7 @@ export class Lessons { after, before, rules, + slots, }); const total = await countRows(baseBuilder.clone(), { @@ -469,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 @@ -518,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; } @@ -528,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/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..814ac5b5d 100644 --- a/packages/models/tests/availabilitySlot.test.ts +++ b/packages/models/tests/availabilitySlot.test.ts @@ -1,4 +1,4 @@ -import { availabilitySlots } from "@/availabilitySlot"; +import { availabilitySlots } from "@/availabilitySlots"; import fixtures from "@fixtures/db"; import { dayjs, nameof, safe } from "@litespace/sol"; import { expect } from "chai"; @@ -56,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({}); @@ -85,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)); }); }); @@ -102,13 +102,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); }); }); @@ -132,11 +134,24 @@ 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 throw an error if the ids list is empty", async () => { - const res = await safe(async () => availabilitySlots.delete([])); - expect(res).to.be.instanceOf(Error); + + 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 [slot] = await availabilitySlots.create([ + { + userId: user.id, + start: dayjs.utc().toISOString(), + end: dayjs.utc().add(1, "hour").toISOString(), + }, + ]); + + 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 5995ab1b6..f22241c92 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 @@ -142,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 = @@ -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/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/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..86b08e534 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 = { @@ -39,4 +45,45 @@ export type SubSlot = { end: string; }; +export type Base = { + start: string; + end: string; +}; + export type GeneralSlot = Slot | SubSlot; + +// API Payloads / Queries +export type FindAvailabilitySlotsApiQuery = SkippablePagination & { + userId: number; + after?: string; + before?: string; +}; + +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 = { + 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 7cef2ebc5..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(); }); } @@ -102,6 +105,24 @@ 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"); + + 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) { const ruleInfo = await rule(payload); return await rules.update(ruleInfo.id, { activated: true }); @@ -124,14 +145,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, userId?: number): Promise { + if (!id) return await slot({ userId }).then((slot) => slot.id); + return id; + }, start(start?: string): string { if (!start) return faker.date.soon().toISOString(); return start; @@ -162,14 +187,19 @@ 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, 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; }); @@ -180,7 +210,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, payload.interviewer), start: or.start(payload.start), }); } @@ -319,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; }) ); @@ -329,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; }) ); @@ -415,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); @@ -480,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 new file mode 100644 index 000000000..58cc70306 --- /dev/null +++ b/services/server/src/handlers/availabilitySlot.ts @@ -0,0 +1,191 @@ +import { bad, conflict, forbidden, notfound } from "@/lib/error"; +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 zod from "zod"; +import { isEmpty } from "lodash"; +import { + deleteSlots, + getSubslots, + isConflictingSlots, +} from "@/lib/availabilitySlot"; +import { MAX_FULL_FLAG_DAYS } from "@/constants"; + +const findPayload = zod.object({ + userId: id, + after: datetime.optional(), + before: datetime.optional(), + page: pageNumber.optional(), + size: pageSize.optional(), + full: jsonBoolean.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, + page, + size, + full, + }: IAvailabilitySlot.FindAvailabilitySlotsApiQuery = 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") <= MAX_FULL_FLAG_DAYS; + + if (full && !canUseFullFlag) return next(bad()); + + const paginatedSlots = await availabilitySlots.find({ + users: [userId], + after, + before, + page, + size, + full, + }); + + // NOTE: return only-slots only if the user is a tutor + const slotIds = paginatedSlots.list.map((slot) => slot.id); + const subslots = + isTutor(user) || isTutorManager(user) + ? [] + : await getSubslots({ + slotIds, + userId, + after, + before, + }); + + const result: IAvailabilitySlot.FindAvailabilitySlotsApiResponse = { + slots: paginatedSlots, + subslots, + }; + + 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( + (slot) => slot.action === "create" + ) as Array; + const updates = payload.slots.filter( + (slot) => slot.action === "update" + ) as Array; + const deletes = payload.slots.filter( + (slot) => slot.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(); + + const conflicting = await isConflictingSlots({ + userId: user.id, + creates, + updates, + deletes, + }); + if (conflicting) return conflict(); + + // 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 + ); + } + + // create slots + if (isEmpty(creates)) return; + 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..c84ffa106 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, @@ -38,6 +45,7 @@ const createInterviewPayload = zod.object({ interviewerId: id, start: datetime, ruleId: id, + slotId: id, }); const updateInterviewPayload = zod.object({ @@ -73,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); @@ -87,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); @@ -108,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/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/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 new file mode 100644 index 000000000..bef1d4773 --- /dev/null +++ b/services/server/src/lib/availabilitySlot.ts @@ -0,0 +1,166 @@ +import { availabilitySlots, interviews, lessons } from "@litespace/models"; +import { asSubSlots, isIntersecting } from "@litespace/sol"; +import { IAvailabilitySlot } from "@litespace/types"; +import { Knex } from "knex"; +import { isEmpty, uniqBy } from "lodash"; +import dayjs from "@/lib/dayjs"; + +export async function deleteSlots({ + currentUserId, + ids, + tx, +}: { + currentUserId: number; + ids: number[]; + 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 associatesCount = associatedLessons.total + associatedInterviews.total; + if (associatesCount <= 0) return await availabilitySlots.delete(ids, tx); + + await Promise.all([ + lessons.cancel({ + ids: associatedLessons.list.map((lesson) => lesson.id), + canceledBy: currentUserId, + tx, + }), + interviews.cancel({ + ids: associatedInterviews.list.map((lesson) => lesson.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, + }); + + const paginatedInterviews = await interviews.find({ + users: [userId], + slots: slotIds, + }); + + 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/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..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"; @@ -36,4 +37,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/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 2b98e2fc0..052f14977 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: [], @@ -53,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, }); @@ -119,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(); @@ -145,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 new file mode 100644 index 000000000..deaeeca49 --- /dev/null +++ b/services/server/tests/api/slot.test.ts @@ -0,0 +1,293 @@ +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 { slots, subslots } = await studentApi.atlas.availabilitySlot.find({ + userId: tutor.id, + after: now.toISOString(), + before: now.add(2, "days").toISOString(), + }); + + 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 + ); + }); + + 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(), + }) + ); + + 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.add(1, "hour").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.add(1, "hour").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.add(1, "hour").toISOString(), + end: now.add(6, "hours").toISOString(), + }, + ], + }) + ); + + 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(conflict()); + }); + + 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(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/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 e69333823..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(); @@ -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: [], @@ -113,6 +126,7 @@ describe("sessions test suite", () => { start: selectedRuleEvent.start, duration: 30, ruleId: rule.id, + slotId: slot.id, tutorId: tutor.user.id, }); @@ -152,6 +166,64 @@ 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, + slotId: slot.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 +236,7 @@ describe("sessions test suite", () => { start: selectedRuleEvent.start, duration: 30, ruleId: rule.id, + slotId: slot.id, tutorId: tutor.user.id, }); @@ -173,8 +246,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();