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
1 change: 1 addition & 0 deletions Backend/SorobanSecurityPortalApi/Common/Data/Db.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class Db : DbContext
public DbSet<BookmarkModel> Bookmark { get; set; }
public DbSet<ModerationLogModel> ModerationLog { get; set; }
public DbSet<UserProfileModel> UserProfiles { get; set; }
public DbSet<ReputationHistoryModel> ReputationHistory { get; set; }
public DbSet<ForumCategoryModel> ForumCategory { get; set; }
public DbSet<ForumThreadModel> ForumThread { get; set; }
public DbSet<ForumPostModel> ForumPost { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using SorobanSecurityPortalApi.Models.DbModels;

#nullable disable

namespace SorobanSecurityPortalApi.Migrations
{
/// <inheritdoc />
public partial class AddReputationHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "reputation_history",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
user_id = table.Column<int>(type: "integer", nullable: false),
points_change = table.Column<int>(type: "integer", nullable: false),
new_reputation = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_reputation_history", x => x.id);
});

migrationBuilder.CreateIndex(
name: "ix_reputation_history_user_id",
table: "reputation_history",
column: "user_id");

migrationBuilder.UpdateData(
table: "login",
keyColumn: "login_id",
keyValue: 1,
columns: new[] { "connected_accounts", "created" },
values: new object[] { new List<ConnectedAccountModel>(), new DateTime(2026, 1, 30, 0, 0, 0, 0, DateTimeKind.Utc) });
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "reputation_history");

migrationBuilder.UpdateData(
table: "login",
keyColumn: "login_id",
keyValue: 1,
columns: new[] { "connected_accounts", "created" },
values: new object[] { new List<ConnectedAccountModel>(), new DateTime(2026, 1, 27, 5, 40, 58, 575, DateTimeKind.Utc).AddTicks(7690) });
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace SorobanSecurityPortalApi.Models.DbModels
{
[Table("reputation_history")]
public class ReputationHistoryModel
{
[Key]
public int Id { get; set; }

[Required]
public int UserId { get; set; }

[Required]
public int PointsChange { get; set; }

[Required]
public int NewReputation { get; set; }

[Required]
[MaxLength(100)]
public string Reason { get; set; } = string.Empty;

[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace SorobanSecurityPortalApi.Services.ControllersServices
{
/// <summary>
/// Service for managing user reputation based on contributions
/// </summary>
public interface IReputationService
{
Task AwardCommentUpvoteAsync(int userId);
Task DeductCommentDownvoteAsync(int userId);
Task AwardReportApprovalAsync(int userId);
Task AwardVulnerabilityAddedAsync(int userId, string severity);
Task AwardRatingPostedAsync(int userId);
Task RecalculateUserReputationAsync(int userId);
Task RecalculateAllUsersReputationAsync();
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ public class ReportService : IReportService
private readonly IReportProcessor _reportProcessor;
private readonly UserContextAccessor _userContextAccessor;
private readonly IGeminiEmbeddingService _embeddingService;
private readonly IReputationService _reputationService;

public ReportService(
IMapper mapper,
IReportProcessor reportProcessor,
UserContextAccessor userContextAccessor,
IGeminiEmbeddingService embeddingService)
IGeminiEmbeddingService embeddingService,
IReputationService reputationService)
{
_mapper = mapper;
_reportProcessor = reportProcessor;
_userContextAccessor = userContextAccessor;
_embeddingService = embeddingService;
_reputationService = reputationService;
}

public async Task<List<ReportViewModel>> Search(ReportSearchViewModel? reportSearchViewModel)
Expand Down Expand Up @@ -99,7 +102,18 @@ public async Task<Result<bool, string>> Approve(int reportId)
var loginId = await _userContextAccessor.GetLoginIdAsync();
if (!await CanApproveReport(reportModel, loginId))
return new Result<bool, string>.Err("You cannot approve this report.");

// Check if report was already approved to avoid duplicate reputation awards
var wasAlreadyApproved = reportModel.Status == ReportModelStatus.Approved;

await _reportProcessor.Approve(reportModel, loginId);

// Award reputation to the report creator (only if newly approved)
if (!wasAlreadyApproved)
{
await _reputationService.AwardReportApprovalAsync(reportModel.CreatedBy);
}

return new Result<bool, string>.Ok(true);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SorobanSecurityPortalApi.Common.Data;
using SorobanSecurityPortalApi.Models.DbModels;

namespace SorobanSecurityPortalApi.Services.ControllersServices
{
public class ReputationService : IReputationService
{
private readonly IDbContextFactory<Db> _dbFactory;
private readonly ILogger<ReputationService> _logger;

// Point values
private const int PointsCommentUpvote = 5;
private const int PointsCommentDownvote = -2;
private const int PointsReportApproved = 25;
private const int PointsRatingPosted = 10;

// Vulnerability points
private const int PointsVulnerabilityCritical = 50;
private const int PointsVulnerabilityHigh = 30;
private const int PointsVulnerabilityMedium = 15;
private const int PointsVulnerabilityLow = 5;

public ReputationService(IDbContextFactory<Db> dbFactory, ILogger<ReputationService> logger)
{
_dbFactory = dbFactory;
_logger = logger;
}

public async Task AwardCommentUpvoteAsync(int userId)
{
await UpdateReputationAsync(userId, PointsCommentUpvote, "comment_upvote_received");
}

public async Task DeductCommentDownvoteAsync(int userId)
{
await UpdateReputationAsync(userId, PointsCommentDownvote, "comment_downvote_received");
}

public async Task AwardReportApprovalAsync(int userId)
{
await UpdateReputationAsync(userId, PointsReportApproved, "report_approved");
}

public async Task AwardVulnerabilityAddedAsync(int userId, string severity)
{
var points = GetVulnerabilityPoints(severity);
await UpdateReputationAsync(userId, points, $"vulnerability_added_{severity.ToLowerInvariant()}");
}

public async Task AwardRatingPostedAsync(int userId)
{
await UpdateReputationAsync(userId, PointsRatingPosted, "rating_posted");
}

public async Task RecalculateUserReputationAsync(int userId)
{
await using var db = await _dbFactory.CreateDbContextAsync();

// Get user profile or create if doesn't exist
var userProfile = await db.UserProfiles
.FirstOrDefaultAsync(up => up.LoginId == userId);

if (userProfile == null)
{
// Create user profile if it doesn't exist
userProfile = new UserProfileModel
{
LoginId = userId,
ReputationScore = 0,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
db.UserProfiles.Add(userProfile);
await db.SaveChangesAsync();
}

// Calculate reputation from all contributions
int totalReputation = 0;

// Reports approved
var approvedReports = await db.Report
.Where(r => r.CreatedBy == userId && r.Status == ReportModelStatus.Approved)
.CountAsync();
totalReputation += approvedReports * PointsReportApproved;

// Vulnerabilities added (by severity)
var vulnerabilities = await db.Vulnerability
.Where(v => v.CreatedBy == userId && v.Status == VulnerabilityModelStatus.Approved)
.ToListAsync();

foreach (var vuln in vulnerabilities)
{
totalReputation += GetVulnerabilityPoints(vuln.Severity);
}

// TODO: Comments and ratings are not yet implemented in the system
// When implemented, include in the recalculation:
// - Count comment upvotes/downvotes
// - Count ratings posted

// Update reputation
var oldReputation = userProfile.ReputationScore;
userProfile.ReputationScore = totalReputation;
userProfile.UpdatedAt = DateTime.UtcNow;

// Log the recalculation
if (oldReputation != totalReputation)
{
var history = new ReputationHistoryModel
{
UserId = userId,
PointsChange = totalReputation - oldReputation,
NewReputation = totalReputation,
Reason = "recalculation",
CreatedAt = DateTime.UtcNow
};
db.ReputationHistory.Add(history);
}

db.UserProfiles.Update(userProfile);
await db.SaveChangesAsync();

_logger.LogInformation("Recalculated reputation for user {UserId}: {OldReputation} -> {NewReputation}",
userId, oldReputation, totalReputation);
}

public async Task RecalculateAllUsersReputationAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();

// Get all users who have created reports or vulnerabilities
var userIds = await db.Report
.Select(r => r.CreatedBy)
.Union(db.Vulnerability.Select(v => v.CreatedBy))
.Distinct()
.ToListAsync();

_logger.LogInformation("Starting reputation recalculation for {Count} users", userIds.Count);

foreach (var userId in userIds)
{
try
{
await RecalculateUserReputationAsync(userId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recalculating reputation for user {UserId}", userId);
}
}

_logger.LogInformation("Completed reputation recalculation for {Count} users", userIds.Count);
}

private async Task UpdateReputationAsync(int userId, int pointsChange, string reason)
{
await using var db = await _dbFactory.CreateDbContextAsync();

// Get or create user profile
var userProfile = await db.UserProfiles
.FirstOrDefaultAsync(up => up.LoginId == userId);

if (userProfile == null)
{
userProfile = new UserProfileModel
{
LoginId = userId,
ReputationScore = 0,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
db.UserProfiles.Add(userProfile);
await db.SaveChangesAsync();
}

var oldReputation = userProfile.ReputationScore;
userProfile.ReputationScore += pointsChange;
userProfile.UpdatedAt = DateTime.UtcNow;

// Log the change
var history = new ReputationHistoryModel
{
UserId = userId,
PointsChange = pointsChange,
NewReputation = userProfile.ReputationScore,
Reason = reason,
CreatedAt = DateTime.UtcNow
};

db.ReputationHistory.Add(history);
db.UserProfiles.Update(userProfile);
await db.SaveChangesAsync();

_logger.LogInformation("Updated reputation for user {UserId}: {OldReputation} -> {NewReputation} ({Reason})",
userId, oldReputation, userProfile.ReputationScore, reason);
}

private static int GetVulnerabilityPoints(string severity)
{
return severity.ToLowerInvariant() switch
{
VulnerabilitySeverity.Critical => PointsVulnerabilityCritical,
VulnerabilitySeverity.High => PointsVulnerabilityHigh,
VulnerabilitySeverity.Medium => PointsVulnerabilityMedium,
VulnerabilitySeverity.Low => PointsVulnerabilityLow,
_ => 0
};
}
}
}

Loading