Skip to content
Open
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
51 changes: 46 additions & 5 deletions Backend/SorobanSecurityPortalApi/Controllers/RatingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ public async Task<IActionResult> GetSummary([FromQuery] EntityType entityType, [
return Ok(result);
}

[HttpGet("summary/weighted")]
public async Task<IActionResult> GetSummaryWeighted([FromQuery] EntityType entityType, [FromQuery] int entityId)
{
if (entityId <= 0)
{
return BadRequest("EntityId must be a positive integer.");
}

var result = await _ratingService.GetSummaryWeighted(entityType, entityId);
return Ok(result);
}

[HttpGet]
public async Task<IActionResult> GetRatings([FromQuery] EntityType entityType, [FromQuery] int entityId, [FromQuery] int page = 1)
{
Expand All @@ -45,21 +57,50 @@ public async Task<IActionResult> GetRatings([FromQuery] EntityType entityType, [
return Ok(result);
}

[HttpGet("with-author")]
public async Task<IActionResult> GetRatingsWithAuthor(
[FromQuery] EntityType entityType,
[FromQuery] int entityId,
[FromQuery] int page = 1,
[FromQuery] bool reviewsOnly = false)
{
if (entityId <= 0)
{
return BadRequest("EntityId must be a positive integer.");
}

var result = await _ratingService.GetRatingsWithAuthor(entityType, entityId, page, 10, reviewsOnly);
return Ok(result);
}

[HttpGet("mine")]
[Authorize]
public async Task<IActionResult> GetMyRating([FromQuery] EntityType entityType, [FromQuery] int entityId)
{
if (entityId <= 0)
{
return BadRequest("EntityId must be a positive integer.");
}

var result = await _ratingService.GetMyRating(entityType, entityId);
return Ok(result);
}

[HttpPost]
[Authorize]
public async Task<IActionResult> CreateOrUpdate([FromBody] CreateRatingRequest request)
{

if (request == null)
{
return BadRequest("Request body cannot be null.");
}

if (request.Score < 1 || request.Score > 5)
{
return BadRequest("Score must be between 1 and 5.");
}

var result = await _ratingService.AddOrUpdateRating(request);
return Ok(result);
}
Expand All @@ -69,7 +110,7 @@ public async Task<IActionResult> CreateOrUpdate([FromBody] CreateRatingRequest r
public async Task<IActionResult> Delete(int id)
{
if (id <= 0) return BadRequest("Rating ID must be a positive integer.");

try
{
await _ratingService.DeleteRating(id);
Expand All @@ -85,4 +126,4 @@ public async Task<IActionResult> Delete(int id)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ public class RatingViewModel
public DateTime CreatedAt { get; set; }
}

public class RatingAuthorViewModel
{
public int LoginId { get; set; }
public string FullName { get; set; } = string.Empty;
public int ReputationScore { get; set; }
}

public class RatingWithAuthorViewModel
{
public int Id { get; set; }
public int UserId { get; set; }
public EntityType EntityType { get; set; }
public int EntityId { get; set; }
public int Score { get; set; }
public string Review { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public RatingAuthorViewModel Author { get; set; } = new RatingAuthorViewModel();
}

public class CreateRatingRequest
{
public EntityType EntityType { get; set; }
Expand All @@ -27,11 +46,17 @@ public class RatingSummaryViewModel
{
public EntityType EntityType { get; set; }
public int EntityId { get; set; }
public float AverageScore { get; set; }
public float AverageScore { get; set; }

public int TotalReviews { get; set; }

// Distribution: Key is star (1-5), Value is count
public Dictionary<int, int> Distribution { get; set; } = new Dictionary<int, int>();
}

public class RatingSummaryWeightedViewModel : RatingSummaryViewModel
{
public float WeightedAverageScore { get; set; }
public int WeightedTotalReviews { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ namespace SorobanSecurityPortalApi.Services.ControllersServices
public interface IRatingService
{
Task<RatingSummaryViewModel> GetSummary(EntityType entityType, int entityId);
Task<RatingSummaryWeightedViewModel> GetSummaryWeighted(EntityType entityType, int entityId);
Task<List<RatingViewModel>> GetRatings(EntityType entityType, int entityId, int page, int pageSize = 10);
Task<List<RatingWithAuthorViewModel>> GetRatingsWithAuthor(EntityType entityType, int entityId, int page, int pageSize = 10, bool withReviewsOnly = false);
Task<RatingViewModel?> GetMyRating(EntityType entityType, int entityId);
Task<RatingViewModel> AddOrUpdateRating(CreateRatingRequest request);
Task DeleteRating(int id);
}
Expand All @@ -39,7 +42,7 @@ public RatingService(Db db, IDistributedCache cache, UserContextAccessor userCon
public async Task<RatingSummaryViewModel> GetSummary(EntityType entityType, int entityId)
{
string cacheKey = $"ratings_summary_{entityType}_{entityId}";

// Try get from Cache (Using helper method)
var cached = await GetCachedAsync<RatingSummaryViewModel>(cacheKey);
if (cached != null) return cached;
Expand Down Expand Up @@ -81,6 +84,56 @@ public async Task<RatingSummaryViewModel> GetSummary(EntityType entityType, int
return summary;
}

public async Task<RatingSummaryWeightedViewModel> GetSummaryWeighted(EntityType entityType, int entityId)
{
string cacheKey = $"ratings_summary_weighted_{entityType}_{entityId}";

var cached = await GetCachedAsync<RatingSummaryWeightedViewModel>(cacheKey);
if (cached != null) return cached;

var baseSummary = await GetSummary(entityType, entityId);

var ratingRows = await (
from r in _db.Rating.AsNoTracking()
join up in _db.UserProfiles.AsNoTracking() on r.UserId equals up.LoginId into upj
from up in upj.DefaultIfEmpty()
where r.EntityType == entityType && r.EntityId == entityId
select new
{
r.Score,
Reputation = (int?)up.ReputationScore
}
).ToListAsync();

double weightedSum = 0;
double weightTotal = 0;

foreach (var row in ratingRows)
{
var reputation = row.Reputation ?? 0;
// Weight function: 1..10 based on reputation buckets of 100
var weight = 1 + Math.Min(9, Math.Max(0, reputation / 100));
weightedSum += row.Score * weight;
weightTotal += weight;
}

var weightedAverage = weightTotal > 0 ? (float)Math.Round(weightedSum / weightTotal, 1) : 0f;

var summary = new RatingSummaryWeightedViewModel
{
EntityType = baseSummary.EntityType,
EntityId = baseSummary.EntityId,
AverageScore = baseSummary.AverageScore,
TotalReviews = baseSummary.TotalReviews,
Distribution = baseSummary.Distribution,
WeightedAverageScore = weightedAverage,
WeightedTotalReviews = baseSummary.TotalReviews
};

await SetCachedAsync(cacheKey, summary, TimeSpan.FromMinutes(10));
return summary;
}

public async Task<List<RatingViewModel>> GetRatings(EntityType entityType, int entityId, int page, int pageSize = 10)
{
var query = _db.Rating
Expand All @@ -96,6 +149,52 @@ public async Task<List<RatingViewModel>> GetRatings(EntityType entityType, int e
return _mapper.Map<List<RatingViewModel>>(ratings);
}

public async Task<List<RatingWithAuthorViewModel>> GetRatingsWithAuthor(EntityType entityType, int entityId, int page, int pageSize = 10, bool withReviewsOnly = false)
{
var query =
from r in _db.Rating.AsNoTracking()
join l in _db.Login.AsNoTracking() on r.UserId equals l.LoginId
join up in _db.UserProfiles.AsNoTracking() on l.LoginId equals up.LoginId into upj
from up in upj.DefaultIfEmpty()
where r.EntityType == entityType
&& r.EntityId == entityId
&& (!withReviewsOnly || (r.Review != null && r.Review != ""))
orderby r.CreatedAt descending
select new RatingWithAuthorViewModel
{
Id = r.Id,
UserId = r.UserId,
EntityType = r.EntityType,
EntityId = r.EntityId,
Score = r.Score,
Review = r.Review ?? string.Empty,
CreatedAt = r.CreatedAt,
Author = new RatingAuthorViewModel
{
LoginId = l.LoginId,
FullName = l.FullName,
ReputationScore = up != null ? up.ReputationScore : 0
}
};

return await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}

public async Task<RatingViewModel?> GetMyRating(EntityType entityType, int entityId)
{
var userId = await _userContext.GetLoginIdAsync();
if (userId == 0) throw new UnauthorizedAccessException("User not logged in.");

var rating = await _db.Rating
.AsNoTracking()
.FirstOrDefaultAsync(r => r.UserId == userId && r.EntityType == entityType && r.EntityId == entityId);

return rating == null ? null : _mapper.Map<RatingViewModel>(rating);
}

public async Task<RatingViewModel> AddOrUpdateRating(CreateRatingRequest request)
{
var userId = await _userContext.GetLoginIdAsync();
Expand All @@ -113,7 +212,7 @@ public async Task<RatingViewModel> AddOrUpdateRating(CreateRatingRequest request
{
existing = _mapper.Map<RatingModel>(request);
existing.UserId = userId;

_db.Rating.Add(existing);
}

Expand All @@ -130,9 +229,9 @@ public async Task DeleteRating(int id)

if (rating == null)
throw new KeyNotFoundException($"Rating with id {id} not found.");

// Allow deletion if user owns it OR user is Admin
if (rating.UserId != userId && !await _userContext.IsLoginIdAdmin(userId))
if (rating.UserId != userId && !await _userContext.IsLoginIdAdmin(userId))
throw new UnauthorizedAccessException("You can only delete your own ratings.");

_db.Rating.Remove(rating);
Expand All @@ -145,6 +244,9 @@ private async Task InvalidateSummaryCache(EntityType type, int id)
{
string cacheKey = $"ratings_summary_{type}_{id}";
await _cache.RemoveAsync(cacheKey);

string weightedCacheKey = $"ratings_summary_weighted_{type}_{id}";
await _cache.RemoveAsync(weightedCacheKey);
}

// --- HELPER METHODS FOR CACHING ---
Expand Down
Loading
Loading