Skip to content

Commit

Permalink
add: find and set handlers for availability slots with unit tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
mmoehabb committed Jan 8, 2025
1 parent becee7d commit 447a967
Show file tree
Hide file tree
Showing 22 changed files with 716 additions and 57 deletions.
5 changes: 4 additions & 1 deletion apps/nova/src/components/Lessons/BookLesson.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const ScheduleInterview: React.FC<{
mutation.mutate({
interviewerId: interviewer.data.id,
ruleId: selectedRule.id,
slotId: selectedRule.id,
start: selectedRule.start,
});
}}
Expand Down
22 changes: 22 additions & 0 deletions packages/atlas/src/availabilitySlot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Base } from "@/base";
import { IAvailabilitySlot } from "@litespace/types";

export class AvailabilitySlot extends Base {
async find(
payload: IAvailabilitySlot.FindAvailabilitySlotsApiParams
): Promise<IAvailabilitySlot.FindAvailabilitySlotsApiResponse> {
return await this.get({
route: `/api/v1/availability-slot/`,
payload,
});
}

async set(
payload: IAvailabilitySlot.SetAvailabilitySlotsApiRequest
): Promise<IAvailabilitySlot.FindAvailabilitySlotsApiResponse> {
return await this.post({
route: `/api/v1/availability-slot/set`,
payload,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -145,6 +147,7 @@ export const BookLessonDialog: React.FC<{
const [duration, setDuration] = useState<number>(15);
const [start, setStart] = useState<string | null>(null);
const [ruleId, setRuleId] = useState<number | null>(null);
const [slotId, setSlotId] = useState<number | null>(null);
const [date, setDate] = useState<Dayjs>(dayjs());

const dateBounds = useMemo(() => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}}
/>
</Animation>
Expand All @@ -302,6 +309,7 @@ export const BookLessonDialog: React.FC<{
step === "confirmation" &&
start &&
ruleId &&
slotId &&
!loading ? (
<Animation key="confimration" id="confirmation">
<div className="tw-px-6">
Expand All @@ -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");
}}
Expand Down
19 changes: 15 additions & 4 deletions packages/luna/src/components/Lessons/BookLesson/TimeSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="tw-px-5">
<div
Expand All @@ -26,8 +27,18 @@ export const TimeSelection: React.FC<{
whileTap={{ scale: 0.95 }}
type="button"
key={slot.start}
onClick={() => 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",
Expand Down
18 changes: 13 additions & 5 deletions packages/models/fixtures/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,15 @@ export async function slot(payload?: Partial<IAvailabilitySlot.CreatePayload>) {
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 = {
Expand All @@ -135,6 +139,10 @@ const or = {
if (!id) return await rule().then((rule) => rule.id);
return id;
},
async roomId(roomId?: number): Promise<number> {
if (!roomId) return await makeRoom();
return roomId;
},
async slotId(id?: number): Promise<number> {
if (!id) return await slot().then((slot) => slot.id);
return id;
Expand Down
12 changes: 10 additions & 2 deletions packages/models/migrations/1716146586880_setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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" },
Expand Down
22 changes: 13 additions & 9 deletions packages/models/scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,13 @@ async function main(): Promise<void> {
});

// 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(":")
Expand Down Expand Up @@ -414,11 +414,15 @@ async function main(): Promise<void> {
);
}

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) => {
Expand Down
70 changes: 55 additions & 15 deletions packages/models/src/availabilitySlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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 {
Expand Down Expand Up @@ -60,13 +62,33 @@ export class AvailabilitySlots {

async delete(
ids: number[],
tx?: Knex.Transaction
tx: Knex.Transaction
): Promise<IAvailabilitySlot.Self[]> {
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));
Expand Down Expand Up @@ -112,23 +134,41 @@ export class AvailabilitySlots {
total,
};
}

async findById(id: number, tx?: Knex.Transaction): Promise<IAvailabilitySlot.Self | null> {

async findById(
id: number,
tx?: Knex.Transaction
): Promise<IAvailabilitySlot.Self | null> {
const { list } = await this.find({ slots: [id], tx });
return first(list) || null;
}

async isOwner({
slots,
owner,
tx,
}: WithOptionalTx<{
slots: number[];
owner: number;
}>): Promise<boolean> {
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<boolean> {
const builder = this.builder(tx).whereIn(this.column("id"), slots);
const count = await countRows(builder.clone());
return Number(count) === slots.length;
}

applySearchFilter<R extends object, T>(
builder: Knex.QueryBuilder<R, T>,
{
slots,
users,
after,
before
}: SearchFilter
{ slots, users, after, before }: SearchFilter
): Knex.QueryBuilder<R, T> {
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);
Expand Down
2 changes: 1 addition & 1 deletion packages/models/src/interviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export class Interviews {
rules?: number[];
slots?: number[];
cancelled?: boolean;
pagination?: IFilter.SkippablePagination,
pagination?: IFilter.SkippablePagination;
} & IFilter.Pagination): Promise<Paginated<IInterview.Self>> {
const baseBuilder = this.builder(tx);

Expand Down
Loading

0 comments on commit 447a967

Please sign in to comment.