Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion BackendAcademy/src/users/dto/rate-tutor.dto.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
1 change: 1 addition & 0 deletions BackendAcademy/src/users/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
24 changes: 24 additions & 0 deletions BackendAcademy/src/users/interfaces/review.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
8 changes: 8 additions & 0 deletions BackendAcademy/src/users/tutor-profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------

/**
Expand Down
183 changes: 170 additions & 13 deletions BackendAcademy/src/users/tutor-profile.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 ----------------------------
Expand Down
82 changes: 81 additions & 1 deletion BackendAcademy/src/users/tutor-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +24,7 @@ export interface TutorEarningsSummary {
@Injectable()
export class TutorProfileService {
private readonly profiles: Map<string, TutorProfileEntity> = new Map();
private readonly reviews: Map<string, Review> = new Map();

async create(dto: CreateTutorProfileDto): Promise<TutorProfileEntity> {
const profile = new TutorProfileEntity({
Expand Down Expand Up @@ -59,6 +61,8 @@ export class TutorProfileService {
): Promise<TutorProfileEntity | null> {
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
Expand All @@ -81,17 +85,70 @@ export class TutorProfileService {
async rate(id: string, dto: RateTutorDto): Promise<TutorProfileEntity> {
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<Review[]> {
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<ReputationDetails> {
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<void> {
const profile = this.profiles.get(id);
if (profile) {
profile.coursesCreated += 1;
profile.reputationScore = this.calculateReputation(profile);
profile.updatedAt = new Date();
}
}
Expand Down Expand Up @@ -120,7 +177,30 @@ export class TutorProfileService {
}

async remove(id: string): Promise<boolean> {
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);
}

// ------------------------------------------------------------------
Expand Down