diff --git a/README.md b/README.md index 275e35f..976aeff 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,15 @@ All configuration is managed through `src/config/` and validated at startup. Cop | `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban JSON-RPC endpoint | | `SOROBAN_CONTRACT_ID` | *(empty)* | Deployed escrow contract ID | +## API Endpoints + +- `GET /health` - Health check +- `GET /api/v1/contracts` - Get contracts +- `GET /api/v1/reputation/:id` - Get freelancer reputation profile +- `PUT /api/v1/reputation/:id` - Update freelancer reputation profile + +See [docs/backend/reputation-api.md](docs/backend/reputation-api.md) for detailed Reputation API info. + ## Contributing 1. Fork the repo and create a branch: `git checkout -b feature/-description` diff --git a/docs/backend/reputation-api.md b/docs/backend/reputation-api.md new file mode 100644 index 0000000..ac8cc30 --- /dev/null +++ b/docs/backend/reputation-api.md @@ -0,0 +1,63 @@ +# Reputation Profile API + +This document details the Reputation Profile API within the TalentTrust Backend. + +## Overview + +The Reputation API provides mechanisms to retrieve and update the reputation of freelancers. It uses a 1-5 rating system with comments and tracks metrics such as `score`, `jobsCompleted`, and `totalRatings`. + +## Endpoints + +### 1. Retrieve Freelancer Reputation + +**GET** `/api/v1/reputation/:freelancerId` + +Retrieves the reputation profile of the freelancer. If it doesn't exist, a default empty profile is returned. + +**Response (200 OK):** +```json +{ + "status": "success", + "data": { + "freelancerId": "fl-12345", + "score": 4.5, + "jobsCompleted": 10, + "totalRatings": 15, + "reviews": [ ... ], + "lastUpdated": "2023-11-01T10:00:00.000Z" + } +} +``` + +### 2. Update Freelancer Reputation + +**PUT** `/api/v1/reputation/:freelancerId` + +Adds a new review to the freelancer's profile and automatically recalculates the overall score. Optionally tracks job completion. + +**Request Body:** +```json +{ + "reviewerId": "client-987", + "rating": 5, + "comment": "Excellent work!", + "jobCompleted": true +} +``` + +**Response (200 OK):** +```json +{ + "status": "success", + "data": { + // Updated profile data + } +} +``` + +## Security Assumptions and Threat Scenarios + +- **Input Validation**: The API ensures rating values strictly fall between 1 and 5. Missing mandatory ID fields are aggressively rejected with a 400 Bad Request. +- **Data Integrity**: Average score computations are protected from division-by-zero bounds during their update cycle. +- **Current Limitation (Mock Storage)**: Since the state is in-memory for the time being, data does not persist across backend restarts. In subsequent releases, data must be securely mapped to a database or immutable ledger (Stellar/Soroban). +- **Authentication**: Currently, the routes are unprotected for testing. In production, these calls must be protected by JWT and role-based checks (e.g., verifying `reviewerId` matches the authenticated client session and that the job relationship actually exists). diff --git a/src/controllers/reputation.controller.test.ts b/src/controllers/reputation.controller.test.ts new file mode 100644 index 0000000..7113e5e --- /dev/null +++ b/src/controllers/reputation.controller.test.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; +import { ReputationController } from './reputation.controller'; +import { ReputationService } from '../services/reputation.service'; + +jest.mock('../services/reputation.service'); + +describe('ReputationController', () => { + let req: Partial; + let res: Partial; + let jsonMock: jest.Mock; + let statusMock: jest.Mock; + + beforeEach(() => { + jsonMock = jest.fn(); + statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + req = { params: { id: 'user-1' }, body: {} }; + res = { status: statusMock } as unknown as Response; + jest.clearAllMocks(); + }); + + describe('getProfile', () => { + it('should return 400 if service throws Freelancer ID is required', () => { + (ReputationService.getProfile as jest.Mock).mockImplementation(() => { + throw new Error('Freelancer ID is required'); + }); + + ReputationController.getProfile(req as Request, res as Response); + expect(statusMock).toHaveBeenCalledWith(400); + expect(jsonMock).toHaveBeenCalledWith({ status: 'error', message: 'Freelancer ID is required' }); + }); + + it('should return 500 for unknown service errors in getProfile', () => { + (ReputationService.getProfile as jest.Mock).mockImplementation(() => { + throw new Error('Database down'); + }); + + ReputationController.getProfile(req as Request, res as Response); + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ status: 'error', message: 'Internal server error' }); + }); + }); + + describe('updateProfile', () => { + it('should return 500 for unknown service errors in updateProfile', () => { + req.body = { reviewerId: 'client-1', rating: 5 }; + (ReputationService.updateProfile as jest.Mock).mockImplementation(() => { + throw new Error('Unexpected failure'); + }); + + ReputationController.updateProfile(req as Request, res as Response); + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ status: 'error', message: 'Internal server error' }); + }); + }); +}); diff --git a/src/controllers/reputation.controller.ts b/src/controllers/reputation.controller.ts new file mode 100644 index 0000000..2355b45 --- /dev/null +++ b/src/controllers/reputation.controller.ts @@ -0,0 +1,57 @@ +import { Request, Response } from 'express'; +import { ReputationService } from '../services/reputation.service'; +import { UpdateReputationPayload } from '../types/reputation'; + +/** + * @title Reputation Controller + * @dev NatSpec: Controller handling HTTP requests for the Freelancer Reputation Profile API. + */ +export class ReputationController { + /** + * @notice Get a freelancer's reputation profile + * @param req The Express request containing freelancerId in params + * @param res The Express response object + */ + public static getProfile(req: Request, res: Response): void { + try { + const { id } = req.params; + const profile = ReputationService.getProfile(id); + + // We always return a profile, defaulting to an empty one if no rating exists + res.status(200).json({ status: 'success', data: profile }); + } catch (error: any) { + if (error.message === 'Freelancer ID is required') { + res.status(400).json({ status: 'error', message: error.message }); + } else { + res.status(500).json({ status: 'error', message: 'Internal server error' }); + } + } + } + + /** + * @notice Update a freelancer's reputation profile with a new review + * @param req The Express request containing freelancerId in params and payload in body + * @param res The Express response object + */ + public static updateProfile(req: Request, res: Response): void { + try { + const { id } = req.params; + const payload: UpdateReputationPayload = req.body; + + // Basic input validation handles securely before reaching service + if (!payload || !payload.reviewerId || typeof payload.rating !== 'number') { + res.status(400).json({ status: 'error', message: 'Invalid payload: reviewerId and rating are required' }); + return; + } + + const updatedProfile = ReputationService.updateProfile(id, payload); + res.status(200).json({ status: 'success', data: updatedProfile }); + } catch (error: any) { + if (error.message.includes('required') || error.message.includes('Rating must be between 1 and 5')) { + res.status(400).json({ status: 'error', message: error.message }); + } else { + res.status(500).json({ status: 'error', message: 'Internal server error' }); + } + } + } +} diff --git a/src/models/reputation.store.test.ts b/src/models/reputation.store.test.ts new file mode 100644 index 0000000..e3b7da6 --- /dev/null +++ b/src/models/reputation.store.test.ts @@ -0,0 +1,39 @@ +import { reputationStore } from './reputation.store'; +import { ReputationProfile } from '../types/reputation'; + +describe('ReputationStore', () => { + const profile: ReputationProfile = { + freelancerId: 'user-1', + score: 5, + jobsCompleted: 1, + totalRatings: 1, + reviews: [], + lastUpdated: new Date().toISOString() + }; + + beforeEach(() => { + reputationStore.clear(); + }); + + it('should set and get a profile', () => { + reputationStore.set(profile); + expect(reputationStore.get('user-1')).toEqual(profile); + }); + + it('should return undefined if profile does not exist', () => { + expect(reputationStore.get('unknown')).toBeUndefined(); + }); + + it('should correctly report if a profile exists (has)', () => { + reputationStore.set(profile); + expect(reputationStore.has('user-1')).toBe(true); + expect(reputationStore.has('user-2')).toBe(false); + }); + + it('should delete a profile', () => { + reputationStore.set(profile); + expect(reputationStore.has('user-1')).toBe(true); + reputationStore.delete('user-1'); + expect(reputationStore.has('user-1')).toBe(false); + }); +}); diff --git a/src/models/reputation.store.ts b/src/models/reputation.store.ts new file mode 100644 index 0000000..54acc21 --- /dev/null +++ b/src/models/reputation.store.ts @@ -0,0 +1,57 @@ +import { ReputationProfile } from '../types/reputation'; + +/** + * @title Reputation Store + * @dev NatSpec: Mock in-memory persistence layer for Freelancer Reputation Profiles. + */ +class ReputationStore { + private profiles: Map; + + constructor() { + this.profiles = new Map(); + } + + /** + * @notice Get a profile by freelancerId + * @param id The freelancer identifier + * @return The profile if found, otherwise undefined + */ + public get(id: string): ReputationProfile | undefined { + return this.profiles.get(id); + } + + /** + * @notice Set or update a profile + * @param profile The full reputation profile object + */ + public set(profile: ReputationProfile): void { + this.profiles.set(profile.freelancerId, profile); + } + + /** + * @notice Check if a profile exists + * @param id The freelancer identifier + * @return True if exists, else false + */ + public has(id: string): boolean { + return this.profiles.has(id); + } + + /** + * @notice Delete a profile (useful for tests) + * @param id The freelancer identifier + */ + public delete(id: string): void { + this.profiles.delete(id); + } + + /** + * @notice Clear all profiles (useful for tests) + */ + public clear(): void { + this.profiles.clear(); + } +} + +// Export a singleton instance for simplicity +export const reputationStore = new ReputationStore(); diff --git a/src/routes/reputation.api.test.ts b/src/routes/reputation.api.test.ts new file mode 100644 index 0000000..5d8731c --- /dev/null +++ b/src/routes/reputation.api.test.ts @@ -0,0 +1,68 @@ +import request from 'supertest'; +import app from '../index'; +import { reputationStore } from '../models/reputation.store'; + +describe('Reputation API Integration Tests', () => { + const freelancerId = 'api-user-123'; + + beforeEach(() => { + reputationStore.clear(); + }); + + describe('GET /api/v1/reputation/:id', () => { + it('should return a default profile for a new user', async () => { + const response = await request(app).get(`/api/v1/reputation/${freelancerId}`); + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + expect(response.body.data.freelancerId).toBe(freelancerId); + expect(response.body.data.score).toBe(0); + expect(response.body.data.totalRatings).toBe(0); + }); + }); + + describe('PUT /api/v1/reputation/:id', () => { + it('should fail with 400 for invalid payload (missing rating)', async () => { + const response = await request(app) + .put(`/api/v1/reputation/${freelancerId}`) + .send({ reviewerId: 'client-1' }); + + expect(response.status).toBe(400); + expect(response.body.message).toContain('reviewerId and rating are required'); + }); + + it('should fail with 400 for invalid rating bounds', async () => { + const response = await request(app) + .put(`/api/v1/reputation/${freelancerId}`) + .send({ reviewerId: 'client-1', rating: 10 }); + + expect(response.status).toBe(400); + expect(response.body.message).toContain('Rating must be between 1 and 5'); + }); + + it('should successfully update and return the new profile', async () => { + const response = await request(app) + .put(`/api/v1/reputation/${freelancerId}`) + .send({ reviewerId: 'client-1', rating: 5, comment: 'Awesome', jobCompleted: true }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + expect(response.body.data.score).toBe(5); + expect(response.body.data.jobsCompleted).toBe(1); + }); + + it('should handle sequential updates correctly via the API', async () => { + await request(app) + .put(`/api/v1/reputation/${freelancerId}`) + .send({ reviewerId: 'client-1', rating: 3, jobCompleted: true }); + + const response = await request(app) + .put(`/api/v1/reputation/${freelancerId}`) + .send({ reviewerId: 'client-2', rating: 4, jobCompleted: true }); + + expect(response.status).toBe(200); + expect(response.body.data.score).toBe(3.5); + expect(response.body.data.totalRatings).toBe(2); + expect(response.body.data.jobsCompleted).toBe(2); + }); + }); +}); diff --git a/src/routes/reputation.routes.ts b/src/routes/reputation.routes.ts new file mode 100644 index 0000000..4aa93ac --- /dev/null +++ b/src/routes/reputation.routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { ReputationController } from '../controllers/reputation.controller'; + +const router = Router(); + +/** + * @title Reputation API Routes + * @dev NatSpec: Exposes REST API paths for the Freelancer Reputation Profile API. + */ + +// GET /api/v1/reputation/:id - Retrieve reputation for a freelancer +router.get('/:id', ReputationController.getProfile); + +// PUT /api/v1/reputation/:id - Update reputation for a freelancer (add review) +router.put('/:id', ReputationController.updateProfile); + +export default router; diff --git a/src/services/reputation.service.test.ts b/src/services/reputation.service.test.ts new file mode 100644 index 0000000..4a7b2e1 --- /dev/null +++ b/src/services/reputation.service.test.ts @@ -0,0 +1,82 @@ +import { ReputationService } from './reputation.service'; +import { reputationStore } from '../models/reputation.store'; +import { UpdateReputationPayload } from '../types/reputation'; + +describe('ReputationService', () => { + const freelancerId = 'user-123'; + + beforeEach(() => { + reputationStore.clear(); + }); + + describe('getProfile', () => { + it('should return a default profile if none exists', () => { + const profile = ReputationService.getProfile(freelancerId); + expect(profile).toBeDefined(); + expect(profile.freelancerId).toBe(freelancerId); + expect(profile.score).toBe(0.0); + expect(profile.totalRatings).toBe(0); + expect(profile.reviews.length).toBe(0); + }); + + it('should throw an error if freelancerId is missing', () => { + expect(() => ReputationService.getProfile('')).toThrow('Freelancer ID is required'); + }); + + it('should return the existing profile', () => { + // Create it via update + ReputationService.updateProfile(freelancerId, { + reviewerId: 'client-1', + rating: 4, + jobCompleted: true + }); + + const profile = ReputationService.getProfile(freelancerId); + expect(profile.score).toBe(4); + expect(profile.jobsCompleted).toBe(1); + }); + }); + + describe('updateProfile', () => { + it('should throw error if freelancerId is missing', () => { + expect(() => { + ReputationService.updateProfile('', { reviewerId: 'xx', rating: 5 }); + }).toThrow('Freelancer ID is required'); + }); + + it('should throw error if rating is invalid', () => { + expect(() => { + ReputationService.updateProfile(freelancerId, { reviewerId: 'xx', rating: 6 }); + }).toThrow('Rating must be between 1 and 5'); + }); + + it('should throw error if reviewerId is missing', () => { + expect(() => { + ReputationService.updateProfile(freelancerId, { reviewerId: '', rating: 5 }); + }).toThrow('Reviewer ID is required'); + }); + + it('should correctly calculate the average score and update jobs completed', () => { + // 1st review + let profile = ReputationService.updateProfile(freelancerId, { + reviewerId: 'client-A', + rating: 4, + comment: 'Good', + jobCompleted: true + }); + expect(profile.score).toBe(4); + expect(profile.totalRatings).toBe(1); + expect(profile.jobsCompleted).toBe(1); + + // 2nd review + profile = ReputationService.updateProfile(freelancerId, { + reviewerId: 'client-B', + rating: 5, + jobCompleted: false + }); + expect(profile.score).toBe(4.5); + expect(profile.totalRatings).toBe(2); + expect(profile.jobsCompleted).toBe(1); // Didn't complete a job + }); + }); +}); diff --git a/src/services/reputation.service.ts b/src/services/reputation.service.ts new file mode 100644 index 0000000..55af9c6 --- /dev/null +++ b/src/services/reputation.service.ts @@ -0,0 +1,85 @@ +import { ReputationProfile, UpdateReputationPayload, Review } from '../types/reputation'; +import { reputationStore } from '../models/reputation.store'; + +/** + * @title Reputation Service + * @dev NatSpec: Business logic for retrieving and updating freelancer reputation profiles. + */ +export class ReputationService { + /** + * @notice Retrieves a freelancer's reputation profile or creates a default one if it doesn't exist + * @param freelancerId The unique identifier of the freelancer + * @return A valid ReputationProfile object + */ + public static getProfile(freelancerId: string): ReputationProfile { + if (!freelancerId) { + throw new Error('Freelancer ID is required'); + } + + let profile = reputationStore.get(freelancerId); + if (!profile) { + // Return a default profile instead of throwing 404, assuming first time access + profile = { + freelancerId, + score: 0.0, + jobsCompleted: 0, + totalRatings: 0, + reviews: [], + lastUpdated: new Date().toISOString(), + }; + // Do not implicitly save here; only save on update. + // This pattern is common - if no rating, return empty state. + } + return profile; + } + + /** + * @notice Updates a freelancer's reputation profile with a new review + * @param freelancerId The unique identifier of the freelancer + * @param payload The review and optional job completion flag + * @return The updated ReputationProfile object + */ + public static updateProfile(freelancerId: string, payload: UpdateReputationPayload): ReputationProfile { + if (!freelancerId) { + throw new Error('Freelancer ID is required'); + } + + if (payload.rating < 1 || payload.rating > 5) { + throw new Error('Rating must be between 1 and 5'); + } + + if (!payload.reviewerId) { + throw new Error('Reviewer ID is required'); + } + + // Get current profile or create initial state + const profile = this.getProfile(freelancerId); + + // Create new review + const newReview: Review = { + reviewerId: payload.reviewerId, + rating: payload.rating, + comment: payload.comment, + createdAt: new Date().toISOString(), + }; + + // Update reviews array + profile.reviews.push(newReview); + + // Update aggregate stats + const totalScore = profile.reviews.reduce((acc, curr) => acc + curr.rating, 0); + profile.totalRatings = profile.reviews.length; + profile.score = parseFloat((totalScore / profile.totalRatings).toFixed(2)); + + if (payload.jobCompleted) { + profile.jobsCompleted += 1; + } + + profile.lastUpdated = new Date().toISOString(); + + // Persist to store + reputationStore.set(profile); + + return profile; + } +} diff --git a/src/types/reputation.ts b/src/types/reputation.ts new file mode 100644 index 0000000..e242d3a --- /dev/null +++ b/src/types/reputation.ts @@ -0,0 +1,27 @@ +/** + * @title Reputation Profile Types + * @dev NatSpec: Types and interfaces for the Freelancer Reputation Profile API. + */ + +export interface Review { + reviewerId: string; + rating: number; // 1-5 scale + comment?: string; + createdAt: string; // ISO 8601 date string +} + +export interface ReputationProfile { + freelancerId: string; + score: number; // Average of all ratings, 0.0 - 5.0 + jobsCompleted: number; + totalRatings: number; + reviews: Review[]; + lastUpdated: string; // ISO 8601 date string +} + +export interface UpdateReputationPayload { + reviewerId: string; + rating: number; + comment?: string; + jobCompleted?: boolean; +}