Skip to content

Commit

Permalink
added(server/api): availability slots "find" and "set" api handlers. (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
neuodev authored Jan 15, 2025
2 parents 5a4dd42 + 8d1ebd7 commit 3a9f0b2
Show file tree
Hide file tree
Showing 42 changed files with 1,279 additions and 116 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
3 changes: 3 additions & 0 deletions packages/atlas/src/atlas.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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);
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(
params: IAvailabilitySlot.FindAvailabilitySlotsApiQuery
): Promise<IAvailabilitySlot.FindAvailabilitySlotsApiResponse> {
return await this.get({
route: `/api/v1/availability-slot/`,
params,
});
}

async set(
payload: IAvailabilitySlot.SetAvailabilitySlotsApiPayload
): Promise<IAvailabilitySlot.FindAvailabilitySlotsApiResponse> {
return await this.post({
route: `/api/v1/availability-slot/`,
payload,
});
}
}
3 changes: 3 additions & 0 deletions packages/headless/src/lessons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,21 @@ export function useCreateLesson({
async ({
tutorId,
ruleId,
slotId,
start,
duration,
}: {
tutorId: number;
ruleId: number;
slotId: number;
start: string;
duration: ILesson.Duration;
}) => {
return await atlas.lesson.create({
tutorId,
duration,
ruleId,
slotId,
start,
});
},
Expand Down
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
46 changes: 40 additions & 6 deletions packages/models/fixtures/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
users,
ratings,
tutors,
availabilitySlots,
} from "@/index";
import {
IInterview,
Expand All @@ -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();
Expand All @@ -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();
});
}
Expand Down Expand Up @@ -102,6 +103,22 @@ export async function rule(payload?: Partial<IRule.CreatePayload>) {
});
}

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");

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<number> {
if (!id) return await tutor().then((tutor) => tutor.id);
Expand All @@ -127,6 +144,10 @@ const or = {
if (!roomId) return await makeRoom();
return roomId;
},
async slotId(id?: number): Promise<number> {
if (!id) return await slot().then((slot) => slot.id);
return id;
},
start(start?: string): string {
if (!start) return faker.date.soon().toISOString();
return start;
Expand Down Expand Up @@ -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;
});
Expand All @@ -176,6 +202,7 @@ export async function interview(payload: Partial<IInterview.CreatePayload>) {
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),
});
}
Expand Down Expand Up @@ -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;
})
);
Expand All @@ -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;
})
);
Expand Down Expand Up @@ -444,6 +477,7 @@ export default {
flush,
topic,
rule,
slot,
room: makeRoom,
rating: makeRating,
message: makeMessage,
Expand Down
11 changes: 11 additions & 0 deletions packages/models/migrations/1716146586880_setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand All @@ -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 },
Expand All @@ -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" },
Expand Down
Loading

0 comments on commit 3a9f0b2

Please sign in to comment.