diff --git a/BackendAcademy/src/users/dto/rate-tutor.dto.ts b/BackendAcademy/src/users/dto/rate-tutor.dto.ts index 04cdd5e9a..91ce7bd5a 100644 --- a/BackendAcademy/src/users/dto/rate-tutor.dto.ts +++ b/BackendAcademy/src/users/dto/rate-tutor.dto.ts @@ -1,6 +1,9 @@ -import { IsNumber, IsOptional, IsString, Min, Max } from 'class-validator'; +import { IsNumber, IsOptional, IsString, IsUUID, Min, Max } from 'class-validator'; export class RateTutorDto { + @IsUUID() + raterUserId: string; + @IsNumber() @Min(1) @Max(5) diff --git a/BackendAcademy/src/users/index.ts b/BackendAcademy/src/users/index.ts index 6eac380db..1a99cac0f 100644 --- a/BackendAcademy/src/users/index.ts +++ b/BackendAcademy/src/users/index.ts @@ -10,5 +10,6 @@ export { ITutorProfile } from './interfaces/tutor-profile.interface'; export { CreateTutorProfileDto } from './dto/create-tutor-profile.dto'; export { UpdateTutorProfileDto } from './dto/update-tutor-profile.dto'; export { RateTutorDto } from './dto/rate-tutor.dto'; +export { Review, ReputationDetails } from './interfaces/review.interface'; export { VerifyTutorDto } from './dto/verify-tutor.dto'; export { RequestVerificationDto } from './dto/request-verification.dto'; diff --git a/BackendAcademy/src/users/interfaces/review.interface.ts b/BackendAcademy/src/users/interfaces/review.interface.ts new file mode 100644 index 000000000..802c9052a --- /dev/null +++ b/BackendAcademy/src/users/interfaces/review.interface.ts @@ -0,0 +1,24 @@ +export interface Review { + id: string; + tutorProfileId: string; + raterUserId: string; + rating: number; + review?: string; + createdAt: Date; +} + +export interface ReputationDetails { + tutorId: string; + reputationScore: number; + averageRating: number; + totalRatings: number; + isVerified: boolean; + coursesCreated: number; + reviewCount: number; + breakdown: { + averageRatingWeight: number; + ratingCountWeight: number; + verifiedWeight: number; + coursesWeight: number; + }; +} diff --git a/BackendAcademy/src/users/tutor-profile.controller.ts b/BackendAcademy/src/users/tutor-profile.controller.ts index 2913b530c..c5c9528d7 100644 --- a/BackendAcademy/src/users/tutor-profile.controller.ts +++ b/BackendAcademy/src/users/tutor-profile.controller.ts @@ -84,6 +84,14 @@ export class TutorProfileController { return this.tutorService.rate(id, dto); } + @Get(':id/reviews') + async getReviews(@Param('id', ParseUUIDPipe) id: string) { + return this.tutorService.getReviews(id); + } + + @Get(':id/reputation') + async getReputation(@Param('id', ParseUUIDPipe) id: string) { + return this.tutorService.getReputation(id); // ---- Verification lifecycle endpoints -------------------------------- /** diff --git a/BackendAcademy/src/users/tutor-profile.service.spec.ts b/BackendAcademy/src/users/tutor-profile.service.spec.ts index fd3f6776a..d5aa1db7e 100644 --- a/BackendAcademy/src/users/tutor-profile.service.spec.ts +++ b/BackendAcademy/src/users/tutor-profile.service.spec.ts @@ -10,6 +10,89 @@ describe('TutorProfileService', () => { service = new TutorProfileService(); }); + describe('Earnings', () => { + it('getEarningsSummary() returns earned XLM and payout details for a tutor', async () => { + const profile = await service.create({ + userId: 'user-1', + bio: 'Test tutor', + specialties: [TutorSpecialty.WEB3_SOROBAN], + hourlyRate: 50, + }); + + await service.updateEarnings(profile.id, 120); + + const summary = await service.getEarningsSummary(profile.id); + + expect(summary).toMatchObject({ + tutorId: profile.id, + earnedXlm: 120, + totalPaidOut: 0, + pendingPayouts: 0, + payouts: [], + }); + }); + + it('getEarningsSummary() throws when the tutor profile does not exist', async () => { + await expect(service.getEarningsSummary('missing-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('Rating and Reviews', () => { + it('rate() stores a review and updates averageRating and totalRatings', async () => { + const profile = await service.create({ + userId: 'user-tutor', + bio: 'Math tutor', + specialties: [TutorSpecialty.RUST_FUNDAMENTALS], + hourlyRate: 40, + }); + + const updated = await service.rate(profile.id, { + raterUserId: 'user-rater', + rating: 5, + review: 'Excellent tutor', + }); + + expect(updated.totalRatings).toBe(1); + expect(updated.averageRating).toBe(5); + expect(updated.reputationScore).toBeGreaterThan(0); + }); + + it('rate() updates aggregate correctly after multiple ratings', async () => { + const profile = await service.create({ + userId: 'user-tutor-2', + bio: 'Rust expert', + specialties: [TutorSpecialty.ADVANCED_RUST], + hourlyRate: 60, + }); + + await service.rate(profile.id, { + raterUserId: 'rater-1', + rating: 5, + }); + await service.rate(profile.id, { + raterUserId: 'rater-2', + rating: 3, + }); + + const updated = await service.rate(profile.id, { + raterUserId: 'rater-3', + rating: 4, + }); + + expect(updated.totalRatings).toBe(3); + expect(updated.averageRating).toBe(4); + expect(updated.reputationScore).toBeGreaterThan(0); + }); + + it('rate() throws when tutor profile does not exist', async () => { + await expect( + service.rate('nonexistent-id', { + raterUserId: 'rater-1', + rating: 5, + }), + ).rejects.toThrow(NotFoundException); // -------------------- Earnings (existing behavior preserved) ----------- it('getEarningsSummary() returns earned XLM and payout details for a tutor', async () => { @@ -19,24 +102,98 @@ describe('TutorProfileService', () => { specialties: [TutorSpecialty.WEB3_SOROBAN], hourlyRate: 50, }); + }); - await service.updateEarnings(profile.id, 120); - - const summary = await service.getEarningsSummary(profile.id); + describe('getReviews()', () => { + it('returns all reviews for a tutor sorted by newest first', async () => { + const profile = await service.create({ + userId: 'user-tutor-3', + bio: 'Bio', + specialties: [TutorSpecialty.ASYNC_RUST], + }); + + await service.rate(profile.id, { + raterUserId: 'rater-1', + rating: 4, + review: 'Good', + }); + await new Promise(r => setTimeout(r, 5)); + await service.rate(profile.id, { + raterUserId: 'rater-2', + rating: 5, + review: 'Great', + }); + + const reviews = await service.getReviews(profile.id); + + expect(reviews).toHaveLength(2); + expect(reviews[0].rating).toBe(5); + expect(reviews[0].raterUserId).toBe('rater-2'); + }); - expect(summary).toMatchObject({ - tutorId: profile.id, - earnedXlm: 120, - totalPaidOut: 0, - pendingPayouts: 0, - payouts: [], + it('throws when tutor profile does not exist', async () => { + await expect(service.getReviews('missing-id')).rejects.toThrow( + NotFoundException, + ); }); }); - it('getEarningsSummary() throws when the tutor profile does not exist', async () => { - await expect(service.getEarningsSummary('missing-id')).rejects.toThrow( - NotFoundException, - ); + describe('getReputation()', () => { + it('returns reputation details for a tutor', async () => { + const profile = await service.create({ + userId: 'user-tutor-4', + bio: 'Verified tutor', + specialties: [TutorSpecialty.WEB3_SOROBAN], + hourlyRate: 100, + }); + + await service.rate(profile.id, { + raterUserId: 'rater-1', + rating: 5, + }); + await service.rate(profile.id, { + raterUserId: 'rater-2', + rating: 4, + }); + + const rep = await service.getReputation(profile.id); + + expect(rep.tutorId).toBe(profile.id); + expect(rep.averageRating).toBe(4.5); + expect(rep.totalRatings).toBe(2); + expect(rep.reviewCount).toBe(2); + expect(rep.reputationScore).toBeGreaterThan(0); + expect(rep.breakdown).toBeDefined(); + expect(rep.breakdown.averageRatingWeight).toBeGreaterThan(0); + expect(rep.breakdown.ratingCountWeight).toBeGreaterThan(0); + }); + + it('reputation score increases when tutor is verified', async () => { + const profile = await service.create({ + userId: 'user-tutor-5', + bio: 'Bio', + specialties: [TutorSpecialty.RUST_FUNDAMENTALS], + }); + + await service.rate(profile.id, { + raterUserId: 'rater-1', + rating: 5, + }); + + const before = await service.getReputation(profile.id); + + await service.update(profile.id, { isVerified: true }); + + const after = await service.getReputation(profile.id); + + expect(after.reputationScore).toBeGreaterThan(before.reputationScore); + }); + + it('throws when tutor profile does not exist', async () => { + await expect(service.getReputation('missing-id')).rejects.toThrow( + NotFoundException, + ); + }); }); // -------------------- Verification lifecycle ---------------------------- diff --git a/BackendAcademy/src/users/tutor-profile.service.ts b/BackendAcademy/src/users/tutor-profile.service.ts index ea651bf81..f87d26eb4 100644 --- a/BackendAcademy/src/users/tutor-profile.service.ts +++ b/BackendAcademy/src/users/tutor-profile.service.ts @@ -3,6 +3,7 @@ import { TutorProfileEntity } from './tutor-profile.entity'; import { CreateTutorProfileDto } from './dto/create-tutor-profile.dto'; import { UpdateTutorProfileDto } from './dto/update-tutor-profile.dto'; import { RateTutorDto } from './dto/rate-tutor.dto'; +import { Review, ReputationDetails } from './interfaces/review.interface'; import { VerifyTutorDto } from './dto/verify-tutor.dto'; import { RequestVerificationDto } from './dto/request-verification.dto'; import { VerificationStatus } from './interfaces/verification-status.enum'; @@ -23,6 +24,7 @@ export interface TutorEarningsSummary { @Injectable() export class TutorProfileService { private readonly profiles: Map = new Map(); + private readonly reviews: Map = new Map(); async create(dto: CreateTutorProfileDto): Promise { const profile = new TutorProfileEntity({ @@ -59,6 +61,8 @@ export class TutorProfileService { ): Promise { const profile = this.profiles.get(id); if (!profile) return null; + Object.assign(profile, dto, { updatedAt: new Date() }); + profile.reputationScore = this.calculateReputation(profile); // Defensive: never allow verification status to be mutated via the // generic update path. Even if a malicious / buggy caller injects // `isVerified` or `status` into the payload, strip them here so they @@ -81,17 +85,70 @@ export class TutorProfileService { async rate(id: string, dto: RateTutorDto): Promise { const profile = this.profiles.get(id); if (!profile) throw new NotFoundException('Tutor profile not found'); + const total = profile.totalRatings * profile.averageRating + dto.rating; profile.totalRatings += 1; profile.averageRating = total / profile.totalRatings; + + const review: Review = { + id: crypto.randomUUID(), + tutorProfileId: id, + raterUserId: dto.raterUserId, + rating: dto.rating, + review: dto.review, + createdAt: new Date(), + }; + this.reviews.set(review.id, review); + + profile.reputationScore = this.calculateReputation(profile); profile.updatedAt = new Date(); return profile; } + async getReviews(tutorProfileId: string): Promise { + const profile = this.profiles.get(tutorProfileId); + if (!profile) throw new NotFoundException('Tutor profile not found'); + + return Array.from(this.reviews.values()) + .filter(r => r.tutorProfileId === tutorProfileId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)); + } + + async getReputation(tutorProfileId: string): Promise { + const profile = this.profiles.get(tutorProfileId); + if (!profile) throw new NotFoundException('Tutor profile not found'); + + const reviewCount = Array.from(this.reviews.values()).filter( + r => r.tutorProfileId === tutorProfileId, + ).length; + + const avgRatingNorm = (profile.averageRating / 5) * 100; + const ratingCountNorm = Math.min(profile.totalRatings / 100, 1) * 100; + const verifiedScore = profile.isVerified ? 100 : 0; + const coursesNorm = Math.min(profile.coursesCreated / 20, 1) * 100; + + return { + tutorId: profile.id, + reputationScore: profile.reputationScore, + averageRating: profile.averageRating, + totalRatings: profile.totalRatings, + isVerified: profile.isVerified, + coursesCreated: profile.coursesCreated, + reviewCount, + breakdown: { + averageRatingWeight: +(avgRatingNorm * 0.5).toFixed(2), + ratingCountWeight: +(ratingCountNorm * 0.15).toFixed(2), + verifiedWeight: +(verifiedScore * 0.2).toFixed(2), + coursesWeight: +(coursesNorm * 0.15).toFixed(2), + }, + }; + } + async incrementCoursesCreated(id: string): Promise { const profile = this.profiles.get(id); if (profile) { profile.coursesCreated += 1; + profile.reputationScore = this.calculateReputation(profile); profile.updatedAt = new Date(); } } @@ -120,7 +177,30 @@ export class TutorProfileService { } async remove(id: string): Promise { - return this.profiles.delete(id); + const deleted = this.profiles.delete(id); + if (deleted) { + for (const [reviewId, review] of this.reviews) { + if (review.tutorProfileId === id) { + this.reviews.delete(reviewId); + } + } + } + return deleted; + } + + private calculateReputation(profile: TutorProfileEntity): number { + const avgRatingNorm = (profile.averageRating / 5) * 100; + const ratingCountNorm = Math.min(profile.totalRatings / 100, 1) * 100; + const verifiedScore = profile.isVerified ? 100 : 0; + const coursesNorm = Math.min(profile.coursesCreated / 20, 1) * 100; + + const score = + avgRatingNorm * 0.5 + + ratingCountNorm * 0.15 + + verifiedScore * 0.2 + + coursesNorm * 0.15; + + return +score.toFixed(2); } // ------------------------------------------------------------------