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..99e74666d --- /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( + payload: IAvailabilitySlot.FindAvailabilitySlotsApiParams + ): Promise { + return await this.get({ + route: `/api/v1/availability-slot/`, + payload, + }); + } + + async set( + payload: IAvailabilitySlot.SetAvailabilitySlotsApiRequest + ): Promise { + return await this.post({ + route: `/api/v1/availability-slot/set`, + 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 f805057da..648c7ee20 100644 --- a/packages/models/fixtures/db.ts +++ b/packages/models/fixtures/db.ts @@ -107,11 +107,15 @@ export async function slot(payload?: Partial) { const start = dayjs.utc(payload?.start || faker.date.future()); const end = start.add(faker.number.int(8), "hours"); - return (await availabilitySlots.create([{ - userId: await or.tutorId(payload?.userId), - start: start.toISOString(), - end: end.toISOString(), - }]))[0]; + return ( + await availabilitySlots.create([ + { + userId: await or.tutorId(payload?.userId), + start: start.toISOString(), + end: end.toISOString(), + }, + ]) + )[0]; } const or = { diff --git a/packages/models/migrations/1716146586880_setup.js b/packages/models/migrations/1716146586880_setup.js index ea1ede3f3..25f78608a 100644 --- a/packages/models/migrations/1716146586880_setup.js +++ b/packages/models/migrations/1716146586880_setup.js @@ -104,7 +104,11 @@ exports.up = (pgm) => { duration: { type: "SMALLINT", notNull: true }, price: { type: "INT", notNull: true }, rule_id: { type: "SERIAL", references: "rules(id)", notNull: true }, - slot_id: { type: "SERIAL", references: "availability_slots(id)", notNull: true }, + slot_id: { + type: "SERIAL", + references: "availability_slots(id)", + notNull: true, + }, session_id: { type: "VARCHAR(50)", notNull: true, primaryKey: true }, canceled_by: { type: "INT", references: "users(id)", default: null }, canceled_at: { type: "TIMESTAMP", default: null }, @@ -126,7 +130,11 @@ exports.up = (pgm) => { interviewee_feedback: { type: "TEXT", default: null }, session_id: { type: "TEXT", notNull: true, primaryKey: true }, rule_id: { type: "SERIAL", references: "rules(id)", notNull: true }, - slot_id: { type: "SERIAL", references: "availability_slots(id)", notNull: true }, + slot_id: { + type: "SERIAL", + references: "availability_slots(id)", + notNull: true, + }, note: { type: "TEXT", default: null }, level: { type: "INT", default: null }, status: { type: "interview_status", default: "pending" }, diff --git a/packages/models/scripts/seed.ts b/packages/models/scripts/seed.ts index 405187390..f4fc8aa2f 100644 --- a/packages/models/scripts/seed.ts +++ b/packages/models/scripts/seed.ts @@ -288,13 +288,13 @@ async function main(): Promise { }); // seeding slots - await availabilitySlots.create(addedTutors.map(tutor => - ({ + await availabilitySlots.create( + addedTutors.map((tutor) => ({ userId: tutor.id, start: dayjs.utc().startOf("day").toISOString(), end: dayjs.utc().startOf("day").add(30, "days").toISOString(), - }) - )); + })) + ); const times = range(0, 24).map((hour) => [hour.toString().padStart(2, "0"), "00"].join(":") @@ -414,11 +414,15 @@ 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]; + 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) => { diff --git a/packages/models/src/availabilitySlots.ts b/packages/models/src/availabilitySlots.ts index 2db44142f..c2c2514cd 100644 --- a/packages/models/src/availabilitySlots.ts +++ b/packages/models/src/availabilitySlots.ts @@ -2,19 +2,21 @@ import { IAvailabilitySlot, IFilter, Paginated } from "@litespace/types"; import dayjs from "@/lib/dayjs"; import { Knex } from "knex"; import { first, isEmpty } from "lodash"; -import { +import { knex, column, countRows, WithOptionalTx, withSkippablePagination, } from "@/query"; +import { lessons } from "@/lessons"; +import { interviews } from "@/interviews"; type SearchFilter = { /** * Slot ids to be included in the search query. */ - slots?: number[], + slots?: number[]; /** * User ids to be included in the search query. */ @@ -29,7 +31,7 @@ type SearchFilter = { * All slots before (or the same as) this date will be included. */ before?: string; - pagination?: IFilter.SkippablePagination, + pagination?: IFilter.SkippablePagination; }; export class AvailabilitySlots { @@ -60,13 +62,33 @@ export class AvailabilitySlots { async delete( ids: number[], - tx?: Knex.Transaction + tx: Knex.Transaction ): Promise { if (isEmpty(ids)) throw new Error("At least one id must be passed."); + await lessons + .builder(tx) + .members.join( + lessons.table.lessons, + lessons.columns.lessons("id"), + lessons.columns.members("lesson_id") + ) + .whereIn(lessons.columns.lessons("slot_id"), ids) + .del(); + + await lessons + .builder(tx) + .lessons.whereIn(lessons.columns.lessons("slot_id"), ids) + .del(); + + await interviews + .builder(tx) + .whereIn(interviews.column("slot_id"), ids) + .del(); + const rows = await this.builder(tx) .whereIn(this.column("id"), ids) - .delete() + .del() .returning("*"); return rows.map((row) => this.from(row)); @@ -112,23 +134,41 @@ export class AvailabilitySlots { total, }; } - - async findById(id: number, tx?: Knex.Transaction): Promise { + + 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 { + 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 { + 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, - { - slots, - users, - after, - before - }: SearchFilter + { slots, users, after, before }: SearchFilter ): Knex.QueryBuilder { - if (slots && !isEmpty(slots)) - builder.whereIn(this.column("id"), slots); + if (slots && !isEmpty(slots)) builder.whereIn(this.column("id"), slots); if (users && !isEmpty(users)) builder.whereIn(this.column("user_id"), users); diff --git a/packages/models/src/interviews.ts b/packages/models/src/interviews.ts index 65b8ed5c1..019201c7c 100644 --- a/packages/models/src/interviews.ts +++ b/packages/models/src/interviews.ts @@ -147,7 +147,7 @@ export class Interviews { rules?: number[]; slots?: number[]; cancelled?: boolean; - pagination?: IFilter.SkippablePagination, + pagination?: IFilter.SkippablePagination; } & IFilter.Pagination): Promise> { const baseBuilder = this.builder(tx); diff --git a/packages/models/tests/availabilitySlot.test.ts b/packages/models/tests/availabilitySlot.test.ts index 6ba952c1c..19ddf0b76 100644 --- a/packages/models/tests/availabilitySlot.test.ts +++ b/packages/models/tests/availabilitySlot.test.ts @@ -1,4 +1,5 @@ import { availabilitySlots } from "@/availabilitySlots"; +import { knex } from "@/query"; import fixtures from "@fixtures/db"; import { dayjs, nameof, safe } from "@litespace/sol"; import { expect } from "chai"; @@ -129,13 +130,21 @@ describe("AvailabilitySlots", () => { }, ]); - await availabilitySlots.delete(created.map((slot) => slot.id)); + await knex.transaction( + async (tx) => + await availabilitySlots.delete( + created.map((slot) => slot.id), + tx + ) + ); const res = await availabilitySlots.find({ users: [user.id] }); expect(res.total).to.eq(0); }); it("should throw an error if the ids list is empty", async () => { - const res = await safe(async () => availabilitySlots.delete([])); + const res = await safe(async () => + knex.transaction(async (tx) => await availabilitySlots.delete([], tx)) + ); expect(res).to.be.instanceOf(Error); }); }); diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index ac0046a4c..b23e2954f 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -38,6 +38,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..fb6110343 100644 --- a/packages/types/src/availabilitySlot.ts +++ b/packages/types/src/availabilitySlot.ts @@ -1,3 +1,6 @@ +import { Paginated } from "@/utils"; +import { SkippablePagination } from "@/filter"; + export type Self = { id: number; userId: number; @@ -23,8 +26,8 @@ export type CreatePayload = { }; export type UpdatePayload = { - start: string; - end: string; + start?: string; + end?: string; }; export type Slot = { @@ -40,3 +43,38 @@ export type SubSlot = { }; export type GeneralSlot = Slot | SubSlot; + +// API Payloads / Queries +export type FindAvailabilitySlotsApiParams = { + userId: number; + after: string; + before: string; + pagination: SkippablePagination; +}; + +// API Requests +export type SetAvailabilitySlotsApiRequest = { + slots: Array< + | { + action: "create"; + start: string; + end: string; + } + | { + action: "update"; + id: number; + start?: string; + end?: string; + } + | { + action: "delete"; + id: number; + } + >; +}; + +// API Responses +export type FindAvailabilitySlotsApiResponse = Paginated<{ + slots: Self[]; + subslots: SubSlot[]; +}>; diff --git a/services/server/fixtures/db.ts b/services/server/fixtures/db.ts index bb0730572..edce235d4 100644 --- a/services/server/fixtures/db.ts +++ b/services/server/fixtures/db.ts @@ -111,11 +111,15 @@ 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]; + return ( + await availabilitySlots.create([ + { + userId: payload?.userId || 1, + start: start.toISOString(), + end: end.toISOString(), + }, + ]) + )[0]; } async function activatedRule(payload?: Partial) { @@ -140,8 +144,8 @@ 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 slotId(id?: number): Promise { @@ -178,7 +182,7 @@ export async function lesson( : payload?.start || faker.date.soon().toISOString(), duration: payload?.duration || sample([15, 30]), price: payload?.price || faker.number.int(500), - rule: await or.ruleId(payload?.rule), + rule: await or.ruleId(payload?.rule, payload?.tutor), slot: await or.slotId(payload?.slot), student, tutor, @@ -197,7 +201,7 @@ 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..d1d64e1f4 --- /dev/null +++ b/services/server/src/handlers/availabilitySlot.ts @@ -0,0 +1,212 @@ +import { bad, conflict, forbidden, notfound } from "@/lib/error"; +import { datetime, id, pagination } 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 { INTERVIEW_DURATION, isIntersecting } from "@litespace/sol"; +import zod from "zod"; +import { isEmpty } from "lodash"; + +const findPayload = zod.object({ + userId: id, + after: datetime, + before: datetime, + pagination, +}); + +const setPayload = zod.object({ + slots: zod.array( + zod + .object({ + action: zod.literal("create"), + start: datetime, + end: datetime, + }) + .or( + zod + .object({ + action: zod.literal("update"), + id: id, + start: datetime.optional(), + end: datetime.optional(), + }) + .or( + zod.object({ + action: zod.literal("delete"), + id: id, + }) + ) + ) + ), +}); + +type CreateAction = { + action: "create"; + start: string; + end: string; +}; +type UpdateAction = { + action: "update"; + id: number; + start?: string; + end?: string; +}; +type DeleteAction = { + action: "delete"; + id: number; +}; + +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.body); + + const diff = dayjs(before).diff(after, "days"); + if (diff < 0) return next(bad()); + + pagination.full = pagination.full && diff <= 30; + const paginatedSlots = await availabilitySlots.find({ + users: [userId], + after, + before, + pagination, + }); + + const slotIds = paginatedSlots.list.map((slot) => slot.id); + + const paginatedLessons = await lessons.find({ + users: [userId], + slots: slotIds, + after, + before, + }); + + const paginatedInterviews = await interviews.find({ + users: [userId], + slots: slotIds, + }); + + const result: IAvailabilitySlot.FindAvailabilitySlotsApiResponse = { + list: [ + { + slots: paginatedSlots.list, + subslots: [ + ...paginatedLessons.list.map((lesson) => ({ + parent: lesson.slotId, + start: lesson.start, + end: dayjs(lesson.start) + .add(lesson.duration, "minutes") + .toISOString(), + })), + ...paginatedInterviews.list.map((interview) => ({ + parent: interview.ids.slot, + start: interview.start, + end: dayjs(interview.start) + .add(INTERVIEW_DURATION, "minutes") + .toISOString(), + })), + ], + }, + ], + 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)]; + + if (!isEmpty(ids)) { + 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 + if (!isEmpty(deletes)) await availabilitySlots.delete(deleteIds, tx); + + // update slots + if (!isEmpty(updates)) + for (const update of updates) { + await availabilitySlots.update( + update.id, + { + start: update.start, + end: update.end, + }, + tx + ); + } + } + + if (!isEmpty(creates)) { + // check for confliction in creates list + const mySlots = await availabilitySlots.find({ users: [user.id] }); + for (const create of creates) { + if ( + isIntersecting( + { id: 0, start: create.start, end: create.end }, + mySlots.list + ) + ) + return next(conflict()); + } + + // create slots + await availabilitySlots.create( + creates.map((obj) => ({ + userId: user.id, + start: obj.start, + end: obj.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 f28812a55..3a73c14f1 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, availabilitySlots } from "@litespace/models"; +import { + interviews, + rules, + users, + knex, + rooms, + availabilitySlots, +} from "@litespace/models"; import { datetime, id, diff --git a/services/server/src/lib/error.ts b/services/server/src/lib/error.ts index f8adfaf0a..58922c718 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 = () => diff --git a/services/server/src/routes/availabilitySlot.ts b/services/server/src/routes/availabilitySlot.ts new file mode 100644 index 000000000..f89a43cf1 --- /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("/set", availabilitySlot.set); + +export default router; diff --git a/services/server/src/validation/utils.ts b/services/server/src/validation/utils.ts index 1c6867226..bf7d2412a 100644 --- a/services/server/src/validation/utils.ts +++ b/services/server/src/validation/utils.ts @@ -146,4 +146,5 @@ export const pageSize = zod.coerce.number().positive().min(1).int(); export const pagination = 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 6694b8574..052f14977 100644 --- a/services/server/tests/api/lesson.test.ts +++ b/services/server/tests/api/lesson.test.ts @@ -41,7 +41,7 @@ describe("/api/v1/lesson/", () => { title: faker.lorem.words(3), }); - const slot = await db.slot({ + const slot = await db.slot({ userId: tutor.user.id, start: dayjs.utc().startOf("day").toISOString(), end: dayjs.utc().add(10, "day").startOf("day").toISOString(), diff --git a/services/server/tests/api/slot.test.ts b/services/server/tests/api/slot.test.ts new file mode 100644 index 000000000..899522250 --- /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(slots.list).to.be.empty; + }); + + 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 f9b4bb35d..4c9a0eac8 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,7 +107,7 @@ describe("sessions test suite", () => { title: faker.lorem.words(3), }); - const slot = await db.slot({ + const slot = await db.slot({ userId: tutor.user.id, start: now.utc().startOf("day").toISOString(), end: now.utc().add(10, "day").startOf("day").toISOString(), @@ -159,7 +166,7 @@ describe("sessions test suite", () => { title: faker.lorem.words(3), }); - const slot = await db.slot({ + const slot = await db.slot({ userId: tutor.user.id, start: now.utc().startOf("day").toISOString(), end: now.utc().add(10, "day").startOf("day").toISOString(), @@ -211,7 +218,7 @@ describe("sessions test suite", () => { title: faker.lorem.words(3), }); - const slot = await db.slot({ + 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(),