diff --git a/packages/utils/src/routes/api.ts b/packages/utils/src/routes/api.ts new file mode 100644 index 000000000..434619719 --- /dev/null +++ b/packages/utils/src/routes/api.ts @@ -0,0 +1,63 @@ +import { ApiRoutes } from "@/routes/route"; + +type ApiBase = keyof typeof ApiRoutes; + +type RouteDescriptor< + BASE extends ApiBase, + SUB extends keyof (typeof ApiRoutes)[BASE]["routes"], +> = { + base: BASE; + subRoute?: SUB; +}; + +export class ApiRoutesManager { + private readonly apiBasePath: string; + + constructor() { + this.apiBasePath = "/api/v1"; + } + + private replaceParams( + path: Path, + params: Record + ): string { + return path.replace(/:([a-zA-Z0-9]+)/g, (_, key) => { + const value = params[key]; + if (value === undefined) { + throw new Error(`Missing parameter: ${key}`); + } + return value.toString(); + }); + } + + generateUrl< + BASE extends ApiBase, + SUB extends keyof (typeof ApiRoutes)[BASE]["routes"], + >({ + route, + params = {}, + type = "full", + }: { + route: RouteDescriptor; + params?: Record; + type?: "full" | "base"; + }): string { + const baseRoute = ApiRoutes[route.base].base; + const subPath = route.subRoute + ? ApiRoutes[route.base].routes[route.subRoute] + : ""; + + let pathSegment = ""; + switch (type) { + case "full": + pathSegment = `${baseRoute}${subPath}`; + break; + case "base": + pathSegment = baseRoute; + break; + } + + const replacedPath = this.replaceParams(pathSegment, params); + return `${this.apiBasePath}${replacedPath}`; + } +} diff --git a/packages/utils/src/routes/index.ts b/packages/utils/src/routes/index.ts index 320fff60d..c21297361 100644 --- a/packages/utils/src/routes/index.ts +++ b/packages/utils/src/routes/index.ts @@ -1,4 +1,10 @@ -export { Web, Landing, Dashboard, StudentSettingsTabId } from "@/routes/route"; +export { + Web, + Landing, + Dashboard, + StudentSettingsTabId, + ApiRoutes, +} from "@/routes/route"; export { clients } from "@/routes/clients"; export { RoutesManager, @@ -6,3 +12,4 @@ export { PayloadOf, UrlParamsOf, } from "@/routes/core"; +export { ApiRoutesManager } from "@/routes/api"; diff --git a/packages/utils/src/routes/route.ts b/packages/utils/src/routes/route.ts index 0af2d91c5..51984e049 100644 --- a/packages/utils/src/routes/route.ts +++ b/packages/utils/src/routes/route.ts @@ -70,3 +70,252 @@ export type StudentSettingsTabId = | "password" | "notifications" | "topics"; + +export const ApiRouteBaseRoute = "/api/v1"; + +type ApiRoutesType = Record< + string, + { base: ApiRouteBase; routes: Record } +>; + +export type ApiRouteBase = + | "/auth" + | "/contact-request" + | "/user" + | "/lesson" + | "/interview" + | "/availability-slot" + | "/rating" + | "/chat" + | "/plan" + | "/coupon" + | "/invite" + | "/invoice" + | "/topic" + | "/asset" + | "/cache" + | "/session" + | "/fawry" + | "/tx" + | "/sub" + | "/confirmation-code" + | "/report"; + +export const ApiRoutes: ApiRoutesType = { + auth: { + base: "/auth", + routes: { + loginWithPassword: "/password", + loginWithGoogle: "/google", + refreshToken: "/refresh-token", + }, + }, + contactRequest: { + base: "/contact-request", + routes: { + create: "/", + }, + }, + user: { + base: "/user", + routes: { + create: "/", + selectInterviewer: "/interviewer/select", + findCurrentUser: "/current", + findUsers: "/list", + uploadUserImage: "/asset", + uploadTutorAssets: "/asset/tutor", + findTutorMeta: "/tutor/meta", + findTutorInfo: "/tutor/info/:tutorId", + findOnboardedTutors: "/tutor/list/onboarded", + findPersonalizedTutorStats: "/tutor/stats/personalized", + findUncontactedTutors: "/tutor/list/uncontacted", + findTutorStats: "/tutor/stats/:tutor", + findTutorActivityScores: "/tutor/activity/:tutor", + findStudioTutors: "/tutor/all/for/studio", + findStudioTutor: "/tutor/:tutorId/for/studio", + findFullTutors: "/tutor/full-tutors", + findTutoringMinutes: "/tutor/tutoring-minutes", + findPersonalizedStudentStats: "/student/stats/personalized", + findStudentStats: "/student/stats/:student", + findStudios: "/studio/list", + findById: "/:id", + update: "/:id", + }, + }, + lesson: { + base: "/lesson", + routes: { + create: "/", + update: "/", + findAttendedLessonsStats: "/attended/stats", + findLessons: "/list", + findLessonById: "/:id", + cancel: "/:lessonId", + }, + }, + interview: { + base: "/interview", + routes: { + create: "/", + update: "/", + find: "/list", + selectInterviewer: "/select", + }, + }, + availabilitySlot: { + base: "/availability-slot", + routes: { + find: "/", + set: "/", + }, + }, + rating: { + base: "/rating", + routes: { + createRating: "/", + findRaterRatings: "/list/rater/:id", + findRatings: "/list", + findRateeRatings: "/list/ratee/:id", + findTutorRatings: "/list/tutor/:id", + findRatingById: "/:id", + updateRating: "/:id", + deleteRating: "/:id", + }, + }, + chat: { + base: "/chat", + routes: { + createRoom: "/new", + findUserRooms: "/list/rooms/:userId", + findRoomMessages: "/list/:roomId/messages", + findRoomByMembers: "/room/by/members/", + findRoomMembers: "/room/members/:roomId", + updateRoom: "/room/:roomId", + }, + }, + plan: { + base: "/plan", + routes: { + create: "/", + find: "/list", + findById: "/:id", + update: "/:id", + delete: "/:id", + }, + }, + coupon: { + base: "/coupon", + routes: { + create: "/", + findAll: "/list", + findByCode: "/code/:code", + findById: "/:id", + update: "/:id", + delete: "/:id", + }, + }, + invite: { + base: "/invite", + routes: { + create: "/", + findAll: "/list", + findById: "/:id", + update: "/:id", + delete: "/:id", + }, + }, + invoice: { + base: "/invoice", + routes: { + find: "/", + stats: "/stats/:tutorId", + create: "/", + update: "/:invoiceId", + }, + }, + topic: { + base: "/topic", + routes: { + createTopic: "/", + updateTopic: "/:id", + deleteTopic: "/:id", + findTopics: "/list", + findUserTopics: "/of/user", + addUserTopics: "/of/user", + deleteUserTopics: "/of/user", + replaceUserTopics: "/of/user", + }, + }, + asset: { + base: "/asset", + routes: { + sample: "/sample", + }, + }, + cache: { + base: "/cache", + routes: { + flush: "/flush", + }, + }, + session: { + base: "/session", + routes: { + getSessionToken: "/token", + findSessionMembers: "/:sessionId", + }, + }, + fawry: { + base: "/fawry", + routes: { + payWithCard: "/pay/card", + payWithRefNum: "/pay/ref-num", + payWithEWallet: "/pay/e-wallet", + payWithBankInstallments: "/pay/bank-installments", + cancelUnpaidOrder: "/cancel-unpaid-order", + refund: "/refund", + getAddCardTokenUrl: "/card-token/url", + findCardTokens: "/card-token/list", + deleteCardToken: "/card-token", + getPaymentStatus: "/payment-status", + setPaymentStatus: "/payment-status", + syncPaymentStatus: "/payment-status/sync", + }, + }, + transaction: { + base: "/tx", + routes: { + findLast: "/last", + find: "/list", + findById: "/:id", + }, + }, + subscription: { + base: "/sub", + routes: { + findUserSubscription: "/user", + find: "/list", + findById: "/:id", + }, + }, + confirmationCode: { + base: "/confirmation-code", + routes: { + sendVerifyPhoneCode: "/phone/send", + verifyPhoneCode: "/phone/verify", + sendForgetPasswordCode: "/password/send", + confirmForgetPasswordCode: "/password/confirm", + sendEmailVerificationCode: "/email/send", + confirmEmailVerificationCode: "/email/confirm", + }, + }, + report: { + base: "/report", + routes: { + find: "/list", + create: "/", + update: "/", + }, + }, +}; diff --git a/services/server/src/index.ts b/services/server/src/index.ts index e4d0dadc9..efda31fc0 100644 --- a/services/server/src/index.ts +++ b/services/server/src/index.ts @@ -18,6 +18,7 @@ import { msg } from "@/lib/telegram"; import "colors"; import { Wss } from "@litespace/types"; import { isAxiosError } from "axios"; +import { ApiRoutesManager } from "@litespace/utils/routes"; // global error handling // this is needed to prevent the server process from exit. @@ -55,6 +56,8 @@ io.engine.use(onlyForHandshake(authMiddleware({ jwtSecret }))); io.engine.use(onlyForHandshake(authorizeSocket)); io.on("connection", wssHandler); +const apiRoutes = new ApiRoutesManager(); + app.use( logger(function (tokens, req, res) { return [ @@ -68,32 +71,220 @@ app.use( ].join(" "); }) ); + app.use(cors({ credentials: true, origin: isAllowedOrigin })); app.use(json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(authMiddleware({ jwtSecret })); -app.use("/api/v1/auth", routes.auth); -app.use("/api/v1/contact-request", routes.contactRequest); -app.use("/api/v1/user", routes.user(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); -app.use("/api/v1/coupon", routes.coupon); -app.use("/api/v1/invite", routes.invite); -app.use("/api/v1/invoice", routes.invoice(context)); -app.use("/api/v1/topic", routes.topic); -app.use("/api/v1/asset", routes.asset); -app.use("/api/v1/cache", routes.cache); -app.use("/api/v1/asset", routes.asset); -app.use("/api/v1/session", routes.session); -app.use("/api/v1/fawry", routes.fawry(context)); -app.use("/api/v1/tx", routes.transaction); -app.use("/api/v1/sub", routes.subscription); -app.use("/api/v1/confirmation-code", routes.confirmationCode); -app.use("/api/v1/report", routes.report); +app.use( + apiRoutes.generateUrl({ + route: { + base: "auth", + }, + type: "base", + }), + routes.auth +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "contactRequest", + }, + type: "base", + }), + routes.contactRequest +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "user", + }, + type: "base", + }), + routes.user(context) +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "lesson", + }, + type: "base", + }), + routes.lesson(context) +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "interview", + }, + type: "base", + }), + routes.interview +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "availabilitySlot", + }, + type: "base", + }), + routes.availabilitySlot +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "rating", + }, + type: "base", + }), + routes.rating +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "chat", + }, + type: "base", + }), + routes.chat +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "plan", + }, + type: "base", + }), + routes.plan +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "coupon", + }, + type: "base", + }), + routes.coupon +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "invite", + }, + type: "base", + }), + routes.invite +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "invoice", + }, + type: "base", + }), + routes.invoice(context) +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "topic", + }, + type: "base", + }), + routes.topic +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "asset", + }, + type: "base", + }), + routes.asset +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "cache", + }, + type: "base", + }), + routes.cache +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "session", + }, + type: "base", + }), + routes.session +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "fawry", + }, + type: "base", + }), + routes.fawry(context) +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "transaction", + }, + type: "base", + }), + routes.transaction +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "subscription", + }, + type: "base", + }), + routes.subscription +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "confirmationCode", + }, + type: "base", + }), + routes.confirmationCode +); + +app.use( + apiRoutes.generateUrl({ + route: { + base: "report", + }, + type: "base", + }), + routes.report +); app.use(errorHandler); diff --git a/services/server/src/routes/asset.ts b/services/server/src/routes/asset.ts index 2c73fd3b2..13501d2be 100644 --- a/services/server/src/routes/asset.ts +++ b/services/server/src/routes/asset.ts @@ -1,8 +1,10 @@ import { Router } from "express"; import asset from "@/handlers/asset"; +import { ApiRoutes } from "@litespace/utils/routes"; const router = Router(); +const sampleRouts = ApiRoutes.sample.routes; -router.get("/sample", asset.sample); +router.get(sampleRouts.sample, asset.sample); export default router; diff --git a/services/server/src/routes/auth.ts b/services/server/src/routes/auth.ts index dbeb5ee17..18fdff4ae 100644 --- a/services/server/src/routes/auth.ts +++ b/services/server/src/routes/auth.ts @@ -2,13 +2,16 @@ import { Router } from "express"; import auth from "@/handlers/auth"; import rateLimit from "express-rate-limit"; import ms from "ms"; +import { ApiRoutes } from "@litespace/utils/routes"; const router = Router(); -router.post("/password", auth.loginWithPassword); -router.post("/google", auth.loginWithGoogle); +const authRoutes = ApiRoutes.auth.routes; + +router.post(authRoutes.loginWithPassword, auth.loginWithPassword); +router.post(authRoutes.loginWithGoogle, auth.loginWithGoogle); router.post( - "/refresh-token", + authRoutes.refreshToken, rateLimit({ windowMs: ms("1m"), limit: 5 }), auth.refreshAuthToken ); diff --git a/services/server/src/routes/availabilitySlot.ts b/services/server/src/routes/availabilitySlot.ts index 5a480b30c..c730ef9e3 100644 --- a/services/server/src/routes/availabilitySlot.ts +++ b/services/server/src/routes/availabilitySlot.ts @@ -1,9 +1,12 @@ import { Router } from "express"; import availabilitySlot from "@/handlers/availabilitySlot"; +import { ApiRoutes } from "@litespace/utils/routes"; const router = Router(); -router.get("/", availabilitySlot.find); -router.post("/", availabilitySlot.set); +const availabilitySlotRoutes = ApiRoutes.availabilitySlot.routes; + +router.get(availabilitySlotRoutes.find, availabilitySlot.find); +router.post(availabilitySlotRoutes.set, availabilitySlot.set); export default router;