diff --git a/src/config.ts b/src/config.ts index da09399..0dd7162 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,3 +68,6 @@ export const FACE_VALIDATION_ENABLED = !!FACE_VALIDATION_URL; export const ABUSIVE_DETECTION_URL = process.env.ABUSIVE_DETECTION_URL; export const ABUSIVE_DETECTION_ENABLED = !!ABUSIVE_DETECTION_URL; + +export const RECOMMENDATION_URL = process.env.RECOMMENDATION_URL; +export const RECOMMENDATION_ENABLED = !!RECOMMENDATION_URL; diff --git a/src/container.ts b/src/container.ts index 93673bc..580e9c8 100644 --- a/src/container.ts +++ b/src/container.ts @@ -12,6 +12,8 @@ import { FaceValidationService } from "./module/face-validation/face-validation. import { createFaceValidationService } from "./module/face-validation/face-validation.factory"; import { AbusiveDetectionService } from "./module/abusive-detection/abusive-detection.interface"; import { createAbusiveDetectionService } from "@/module/abusive-detection/abusive-detection.factory"; +import { createRecommendationService } from "./module/recommendation/recommendation.factory"; +import { RecommendationService } from "./module/recommendation/recommendation.interface"; interface Container { authRepository: AuthRepository; @@ -25,24 +27,39 @@ interface Container { reviewRepository: ReviewRepository; faceValidationService: FaceValidationService; abusiveDetectionService: AbusiveDetectionService; + recommendationService: RecommendationService; } let containerInstance: Container | null = null; export const setupContainer = (): Container => { if (!containerInstance) { + const authRepository = new AuthRepository(db); + const categoryRepository = new CategoryRepository(db); + const learnerRepository = new LearnerRepository(db); + const tutorRepository = new TutorRepository(db); + const tutoriesRepository = new TutoriesRepository(db); + const orderRepository = new OrderRepository(db); + const chatRepository = new ChatRepository(db); + const fcmRepository = new FCMRepository(db); + const reviewRepository = new ReviewRepository(db); + containerInstance = { - authRepository: new AuthRepository(db), - categoryRepository: new CategoryRepository(db), - learnerRepository: new LearnerRepository(db), - tutorRepository: new TutorRepository(db), - tutoriesRepository: new TutoriesRepository(db), - orderRepository: new OrderRepository(db), - chatRepository: new ChatRepository(db), - fcmRepository: new FCMRepository(db), - reviewRepository: new ReviewRepository(db), + authRepository, + categoryRepository, + learnerRepository, + tutorRepository, + tutoriesRepository, + orderRepository, + chatRepository, + fcmRepository, + reviewRepository, faceValidationService: createFaceValidationService(), abusiveDetectionService: createAbusiveDetectionService(), + recommendationService: createRecommendationService( + learnerRepository, + tutoriesRepository, + ), }; } diff --git a/src/module/recommendation/recommendation.factory.ts b/src/module/recommendation/recommendation.factory.ts new file mode 100644 index 0000000..24e6a99 --- /dev/null +++ b/src/module/recommendation/recommendation.factory.ts @@ -0,0 +1,47 @@ +import { RECOMMENDATION_ENABLED, RECOMMENDATION_URL } from "@/config"; +import { + RemoteRecommendationService, + InterestRecommendationService, +} from "./recommendation.service"; +import { logger } from "@middleware/logging.middleware"; +import axios from "axios"; +import { LearnerRepository } from "../learner/learner.repository"; +import { TutoriesRepository } from "../tutories/tutories.repository"; + +export const createRecommendationService = ( + learnerRepository: LearnerRepository, + tutoriesRepository: TutoriesRepository, +) => { + if (process.env.NODE_ENV === "test" || !RECOMMENDATION_ENABLED) { + logger.info( + "Recommendation service is disabled, using simple interest-matching service", + ); + return new InterestRecommendationService( + learnerRepository, + tutoriesRepository, + ); + } + + const service = new RemoteRecommendationService(RECOMMENDATION_URL!); + + try { + axios + .get(`${RECOMMENDATION_URL}/health`, { + timeout: 5000, + }) + .catch(() => { + throw new Error("Health check failed"); + }); + + logger.info("Recommendation service is healthy and ready"); + return service; + } catch (error) { + logger.warn( + `Recommendation service is unavailable: ${error}, falling back to simple interest-matching service`, + ); + return new InterestRecommendationService( + learnerRepository, + tutoriesRepository, + ); + } +}; diff --git a/src/module/recommendation/recommendation.interface.ts b/src/module/recommendation/recommendation.interface.ts new file mode 100644 index 0000000..96040f5 --- /dev/null +++ b/src/module/recommendation/recommendation.interface.ts @@ -0,0 +1,43 @@ +import { TutorAvailability } from "@/db/schema"; +import { LearningStyle } from "../learner/learner.types"; + +export interface Recommendation { + tutor_id: string; + tutories_id: string; + name: string; + email: string; + city: string | null; + district: string | null; + category: string; + tutory_name: string; + about: string; + methodology: string; + hourly_rate: number; + type_lesson: string; + completed_orders: number; + total_orders: number; + match_reasons?: string[]; + location_match: boolean; + availability: TutorAvailability | null; +} + +export interface RecommendationServiceResponse { + learner: { + id: string; + name: string; + email: string; + learning_style: LearningStyle | null; + city: string | null; + district: string | null; + interests: string[]; + }; + recommendations: Recommendation[]; + total_found: number; + requested: number; +} + +export interface RecommendationService { + getRecommendations(learnerId: string): Promise; + trackInteraction(learnerId: string, tutoriesId: string): Promise; + checkHealth(): Promise; +} diff --git a/src/module/recommendation/recommendation.service.ts b/src/module/recommendation/recommendation.service.ts new file mode 100644 index 0000000..66abc77 --- /dev/null +++ b/src/module/recommendation/recommendation.service.ts @@ -0,0 +1,111 @@ +import axios from "axios"; +import { + Recommendation, + RecommendationService, + RecommendationServiceResponse, +} from "./recommendation.interface"; +import { logger } from "@middleware/logging.middleware"; +import { TutoriesRepository } from "../tutories/tutories.repository"; +import { LearnerRepository } from "../learner/learner.repository"; + +export class RemoteRecommendationService implements RecommendationService { + constructor(private readonly baseUrl: string) {} + + async trackInteraction(learnerId: string, tutoriesId: string): Promise { + try { + axios.get(`${this.baseUrl}/interaction/${learnerId}/${tutoriesId}`); + } catch (error) { + logger.error("Recommendation service error:", error); + throw new Error("Recommendation service unavailable"); + } + } + + async getRecommendations( + learnerId: string, + ): Promise { + try { + const response = await axios.get( + `${this.baseUrl}/recommendations/${learnerId}`, + { timeout: 5000 }, + ); + + return response.data; + } catch (error) { + logger.error("Recommendation service error:", error); + throw new Error("Recommendation service unavailable"); + } + } + + checkHealthSync(): boolean { + try { + const xhr = new XMLHttpRequest(); + xhr.open("GET", `${this.baseUrl}/health`, false); // Make the request synchronous + xhr.timeout = 5000; + xhr.send(null); + + return xhr.status === 200; + } catch (error) { + logger.warn("Health check failed:", error); + return false; + } + } + + async checkHealth(): Promise { + try { + const response = await axios.get(`${this.baseUrl}/health`, { + timeout: 5000, + }); + return response.data.status === "healthy"; + } catch (error) { + return false; + } + } +} + +export class InterestRecommendationService implements RecommendationService { + constructor( + private readonly learnerRepository: LearnerRepository, + private readonly tutoriesRepository: TutoriesRepository, + ) {} + + async trackInteraction(learnerId: string, tutoriesId: string): Promise { + // No-op + } + + async getRecommendations( + learnerId: string, + ): Promise { + const learner = await this.learnerRepository.getLearnerById(learnerId); + if (!learner) { + throw new Error("Learner not found"); + } + + const recommendations: Recommendation[] = + await this.tutoriesRepository.getTutoriesByLearnerInterests(learnerId); + + const result: RecommendationServiceResponse = { + learner: { + id: learner.id, + name: learner.name, + email: learner.email, + learning_style: learner.learningStyle, + city: learner.city, + district: learner.district, + interests: learner.interests, + }, + recommendations, + total_found: 0, + requested: 0, + }; + + return result; + } + + checkHealthSync(): boolean { + return true; + } + + async checkHealth(): Promise { + return true; + } +} diff --git a/src/module/tutories/tutories.controller.ts b/src/module/tutories/tutories.controller.ts index fdeb08e..e248d13 100644 --- a/src/module/tutories/tutories.controller.ts +++ b/src/module/tutories/tutories.controller.ts @@ -3,8 +3,10 @@ import { createTutoriesSchema, deleteTutoriesSchema, getAverageRateSchema, + getRecommendationsSchema, getServiceSchema, getTutoriesSchema, + trackInteractionSchema, updateTutoriesSchema, } from "@/module/tutories/tutories.schema"; import { TutoriesService } from "@/module/tutories/tutories.service"; @@ -18,6 +20,7 @@ const tutoriesService = new TutoriesService({ tutorRepository: container.tutorRepository, reviewRepository: container.reviewRepository, abusiveDetection: container.abusiveDetectionService, + recommender: container.recommendationService, }); type GetTutoriesSchema = z.infer; @@ -276,3 +279,52 @@ export const deleteTutories: Controller = async ( }); } }; + +type GetRecommendationsSchema = z.infer; +export const getRecommendations: Controller = async ( + req, + res, +) => { + const learnerId = req.params.learnerId; + + try { + const recommendations = await tutoriesService.getRecommendations(learnerId); + + res.json({ + status: "success", + data: recommendations, + }); + } catch (error) { + logger.error(`Failed to get recommendations: ${error}`); + + res.status(500).json({ + status: "error", + message: `Failed to get recommendations`, + }); + } +}; + +type TrackInteractionSchema = z.infer; +export const trackInteraction: Controller = async ( + req, + res, +) => { + const learnerId = req.params.learnerId; + const tutoriesId = req.params.tutoriesId; + + try { + await tutoriesService.trackInteraction(learnerId, tutoriesId); + + res.json({ + status: "success", + message: "Interaction tracked successfully", + }); + } catch (error) { + logger.error(`Failed to track interaction: ${error}`); + + res.status(500).json({ + status: "error", + message: `Failed to track interaction`, + }); + } +}; diff --git a/src/module/tutories/tutories.repository.ts b/src/module/tutories/tutories.repository.ts index cdfba1d..4918aad 100644 --- a/src/module/tutories/tutories.repository.ts +++ b/src/module/tutories/tutories.repository.ts @@ -1,5 +1,12 @@ import { db as dbType } from "@/db/config"; -import { categories, orders, tutories, tutors } from "@/db/schema"; +import { + categories, + interests, + learners, + orders, + tutories, + tutors, +} from "@/db/schema"; import { createTutoriesSchema, updateTutoriesSchema, @@ -18,6 +25,7 @@ import { lte, not, or, + sql, } from "drizzle-orm"; import { z } from "zod"; @@ -296,4 +304,46 @@ export class TutoriesRepository { throw new Error(`Error checking if tutories exists: ${error}`); } } + + async getTutoriesByLearnerInterests(learnerId: string) { + return await this.db + .select({ + tutor_id: tutors.id, + tutories_id: tutories.id, + name: tutors.name, + email: tutors.email, + city: tutors.city, + district: tutors.district, + category: categories.name, + tutory_name: tutories.name, + about: tutories.aboutYou, + methodology: tutories.teachingMethodology, + hourly_rate: tutories.hourlyRate, + type_lesson: tutories.typeLesson, + location_match: sql`${learners.city} = ${tutors.city}`.as( + "location_match", + ), + availability: tutors.availability, + completed_orders: sql`( + SELECT COUNT(*) + FROM ${orders} + WHERE ${orders.tutoriesId} = ${tutories.id} + AND ${orders.status} = 'completed' + )`.as("completed_orders"), + total_orders: sql`( + SELECT COUNT(*) + FROM ${orders} + WHERE ${orders.tutoriesId} = ${tutories.id} + )`.as("total_orders"), + }) + .from(tutories) + .innerJoin(tutors, eq(tutories.tutorId, tutors.id)) + .innerJoin(categories, eq(tutories.categoryId, categories.id)) + .innerJoin(interests, eq(interests.categoryId, tutories.categoryId)) + .innerJoin(learners, eq(interests.learnerId, learners.id)) + .where( + and(eq(interests.learnerId, learnerId), eq(tutories.isEnabled, true)), + ) + .limit(5); + } } diff --git a/src/module/tutories/tutories.route.ts b/src/module/tutories/tutories.route.ts index dc0a6ba..6398e6b 100644 --- a/src/module/tutories/tutories.route.ts +++ b/src/module/tutories/tutories.route.ts @@ -3,7 +3,9 @@ import * as tutoriesController from "@/module/tutories/tutories.controller"; import { createTutoriesSchema, getAverageRateSchema, + getRecommendationsSchema, getTutoriesSchema, + trackInteractionSchema, updateTutoriesSchema, } from "@/module/tutories/tutories.schema"; import { validator } from "@middleware/validation.middleware"; @@ -33,12 +35,27 @@ tutoriesRouter.get( tutoriesController.getLocations, ); +tutoriesRouter.get( + "/recommendations/:learnerId", + // #swagger.tags = ['tutors/services'] + validator(getRecommendationsSchema), + tutoriesController.getRecommendations, +); + +tutoriesRouter.get( + "/interaction/:learnerId/:tutoriesId", + validator(trackInteractionSchema), + tutoriesController.trackInteraction, +); + tutoriesRouter.get( "/:tutoriesId", // #swagger.tags = ['tutors/services'] tutoriesController.getTutories, ); +// Tutor only routes + tutoriesRouter.use(verifyTutor); tutoriesRouter.get( diff --git a/src/module/tutories/tutories.schema.ts b/src/module/tutories/tutories.schema.ts index 408a280..7b8894e 100644 --- a/src/module/tutories/tutories.schema.ts +++ b/src/module/tutories/tutories.schema.ts @@ -3,6 +3,8 @@ import { z } from "zod"; const categoryRepository = container.categoryRepository; const tutorRepository = container.tutorRepository; +const learnerRepository = container.learnerRepository; +const tutoriesRepository = container.tutoriesRepository; export const tutoriesSchema = z.object({ id: z.string().optional(), @@ -148,3 +150,48 @@ export const deleteTutoriesSchema = z.object({ tutoriesId: z.string(), }), }); + +export const getRecommendationsSchema = z.object({ + params: z.object({ + learnerId: z.string().superRefine(async (learnerId, ctx) => { + try { + const exists = + await container.learnerRepository.checkLearnerExists(learnerId); + if (!exists) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Learner does not exist", + }); + } + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Failed to check if learner exists ", + }); + } + }), + }), +}); + +export const trackInteractionSchema = z.object({ + params: z.object({ + learnerId: z.string().superRefine(async (learnerId, ctx) => { + const exists = await learnerRepository.checkLearnerExists(learnerId); + if (!exists) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Learner does not exist", + }); + } + }), + tutoriesId: z.string().superRefine(async (tutoriesId, ctx) => { + const exists = await tutoriesRepository.checkTutoriesExists(tutoriesId); + if (!exists) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Tutories does not exist", + }); + } + }), + }), +}); diff --git a/src/module/tutories/tutories.service.ts b/src/module/tutories/tutories.service.ts index 9f9a6d5..4672a49 100644 --- a/src/module/tutories/tutories.service.ts +++ b/src/module/tutories/tutories.service.ts @@ -10,12 +10,14 @@ import { ValidationError } from "../tutor/tutor.error"; import { TutorRepository } from "../tutor/tutor.repository"; import { GetTutoriesFilters } from "@/types"; import { AbusiveDetectionService } from "@/module/abusive-detection/abusive-detection.interface"; +import { RecommendationService } from "../recommendation/recommendation.interface"; export interface TutorServiceServiceDependencies { tutoriesRepository: TutoriesRepository; tutorRepository: TutorRepository; reviewRepository: ReviewRepository; abusiveDetection: AbusiveDetectionService; + recommender: RecommendationService; } export class TutoriesService { @@ -23,17 +25,20 @@ export class TutoriesService { private tutorRepository: TutorRepository; private reviewRepository: ReviewRepository; private abusiveDetection: AbusiveDetectionService; + private recommender: RecommendationService; constructor({ tutoriesRepository, tutorRepository, reviewRepository, abusiveDetection, + recommender, }: TutorServiceServiceDependencies) { this.tutoriesRepository = tutoriesRepository; this.tutorRepository = tutorRepository; this.reviewRepository = reviewRepository; this.abusiveDetection = abusiveDetection; + this.recommender = recommender; } private async validateContent(content: string, fieldName: string) { @@ -126,6 +131,22 @@ export class TutoriesService { } } + async getRecommendations(learnerId: string) { + try { + return await this.recommender.getRecommendations(learnerId); + } catch (error) { + logger.error(`Failed to get recommendations: ${error}`); + } + } + + async trackInteraction(learnerId: string, tutoriesId: string) { + try { + await this.recommender.trackInteraction(learnerId, tutoriesId); + } catch (error) { + logger.error(`Failed to track interaction: ${error}`); + } + } + async createTutories( tutorId: string, data: z.infer["body"],