diff --git a/Backend/Backend/Backend.csproj b/Backend/Backend/Backend.csproj index 1c047f6..9ae8f89 100644 --- a/Backend/Backend/Backend.csproj +++ b/Backend/Backend/Backend.csproj @@ -7,13 +7,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/Backend/Backend/Controllers/AuthController.cs b/Backend/Backend/Controllers/AuthController.cs index 3991508..7ea8469 100644 --- a/Backend/Backend/Controllers/AuthController.cs +++ b/Backend/Backend/Controllers/AuthController.cs @@ -75,15 +75,23 @@ public async Task GitHubLogin([FromBody] string code) { "redirect_uri", _config["GitHub:RedirectUri"] ?? "" } }; - var request = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token") + using var request = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token") { Content = new FormUrlEncodedContent(requestData) }; request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - var response = await client.SendAsync(request); - var result = await response.Content.ReadFromJsonAsync(); - return result?.access_token; + try + { + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + var result = await response.Content.ReadFromJsonAsync(); + return result?.access_token; + } + catch (Exception) + { + return null; + } } private async Task GetGitHubUser(string token) @@ -92,10 +100,16 @@ public async Task GitHubLogin([FromBody] string code) client.DefaultRequestHeaders.UserAgent.ParseAdd("GitQuest-App"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await client.GetAsync("https://api.github.com/user"); - if (!response.IsSuccessStatusCode) return null; - - return await response.Content.ReadFromJsonAsync(); + try + { + var response = await client.GetAsync("https://api.github.com/user"); + if (!response.IsSuccessStatusCode) return null; + return await response.Content.ReadFromJsonAsync(); + } + catch (Exception) + { + return null; + } } private string GenerateJwtToken(User user) diff --git a/Backend/Backend/Controllers/IssuesController.cs b/Backend/Backend/Controllers/IssuesController.cs index 7cc7031..702bd30 100644 --- a/Backend/Backend/Controllers/IssuesController.cs +++ b/Backend/Backend/Controllers/IssuesController.cs @@ -40,10 +40,14 @@ public async Task ClaimQuest(long id) var userId = Guid.Parse(userIdClaim); + // Verify the issue exists in our database and is still active + var issue = await _context.Issues.FirstOrDefaultAsync(i => i.GitHubIssueId == id); + if (issue == null || !issue.IsActive) return NotFound("Issue not found or no longer active."); + var existingQuest = await _context.Quests .FirstOrDefaultAsync(q => q.UserId == userId && q.GitHubIssueId == id && q.Status == "In Progress"); - if (existingQuest != null) return BadRequest("Quest already active."); + if (existingQuest != null) return Conflict("Quest already active."); var quest = new Quest { @@ -55,7 +59,15 @@ public async Task ClaimQuest(long id) }; _context.Quests.Add(quest); - await _context.SaveChangesAsync(); + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateException) + { + // Unique constraint violation: another concurrent request already claimed this quest + return Conflict("Quest already active."); + } return Ok(new { message = "Quest claimed!", questId = quest.Id }); } @@ -67,7 +79,9 @@ public async Task GetMyQuests() if (userIdClaim == null) return Unauthorized(); var userId = Guid.Parse(userIdClaim); - var quests = await _context.Quests.Where(q => q.UserId == userId).ToListAsync(); + var quests = await _context.Quests + .Where(q => q.UserId == userId && q.Status == "In Progress") + .ToListAsync(); return Ok(quests); } @@ -83,17 +97,47 @@ public async Task SubmitQuest(long id) if (quest == null) return NotFound("Quest not found."); + // Resolve the XP reward from the stored Issue record + var issue = await _context.Issues.FirstOrDefaultAsync(i => i.GitHubIssueId == id); + if (issue == null || !issue.IsActive) return BadRequest("Issue is no longer available."); + quest.Status = "Completed"; quest.CompletedAt = DateTime.UtcNow; var user = await _context.Users.FindAsync(userId); if (user != null) { - user.ExperiencePoints += 30; - user.CurrentStreak += 1; + user.ExperiencePoints += issue.XPReward; + + // Update streak based on contribution dates (once per day) + var today = DateTime.UtcNow.Date; + if (user.LastContributionDate.HasValue) + { + var lastDate = user.LastContributionDate.Value.Date; + if (lastDate == today) + { + // Already contributed today – do not increment streak + } + else if (lastDate == today.AddDays(-1)) + { + // Contributed yesterday – extend streak + user.CurrentStreak += 1; + } + else + { + // Gap in contributions – reset streak + user.CurrentStreak = 1; + } + } + else + { + user.CurrentStreak = 1; + } + + user.LastContributionDate = DateTime.UtcNow; } await _context.SaveChangesAsync(); - return Ok(new { message = "30 XP Awarded!", totalXp = user?.ExperiencePoints }); + return Ok(new { message = $"{issue.XPReward} XP Awarded!", totalXp = user?.ExperiencePoints }); } } \ No newline at end of file diff --git a/Backend/Backend/Controllers/UsersController.cs b/Backend/Backend/Controllers/UsersController.cs index 6a82dfe..cc340ba 100644 --- a/Backend/Backend/Controllers/UsersController.cs +++ b/Backend/Backend/Controllers/UsersController.cs @@ -37,6 +37,28 @@ public async Task GetProfile(string username) if (user == null) return NotFound(); - return Ok(user); + var profile = new + { + user.GitHubUsername, + user.AvatarUrl, + user.ExperiencePoints, + user.CurrentStreak, + user.LastContributionDate, + Contributions = user.Contributions.Select(c => new + { + c.CompletedAt, + c.PullRequestUrl, + Issue = c.Issue == null ? null : new + { + c.Issue.Title, + c.Issue.RepoFullName, + c.Issue.IssueUrl, + c.Issue.Difficulty, + c.Issue.XPReward + } + }) + }; + + return Ok(profile); } } \ No newline at end of file diff --git a/Backend/Backend/Migrations/20260315125659_AddUniqueConstraints.Designer.cs b/Backend/Backend/Migrations/20260315125659_AddUniqueConstraints.Designer.cs new file mode 100644 index 0000000..9acdc7e --- /dev/null +++ b/Backend/Backend/Migrations/20260315125659_AddUniqueConstraints.Designer.cs @@ -0,0 +1,244 @@ +// +using System; +using GitQuest.Backend.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(GitQuestContext))] + [Migration("20260315125659_AddUniqueConstraints")] + partial class AddUniqueConstraints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GitQuest.Backend.Models.Issue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Difficulty") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubIssueId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IssueUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Language") + .HasColumnType("nvarchar(max)"); + + b.Property("RepoFullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("XPReward") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GitHubIssueId") + .IsUnique(); + + b.ToTable("Issues"); + }); + + modelBuilder.Entity("GitQuest.Backend.Models.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("HtmlUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerAvatarUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Stars") + .HasColumnType("int"); + + b.Property("TechStackSummary") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("GitQuest.Backend.Models.Quest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GitHubIssueId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GitHubIssueId", "Status") + .IsUnique() + .HasDatabaseName("IX_Quests_UserId_GitHubIssueId_ActiveStatus") + .HasFilter("[Status] = 'In Progress'"); + + b.ToTable("Quests"); + }); + + modelBuilder.Entity("GitQuest.Backend.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvatarUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("CurrentStreak") + .HasColumnType("int"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExperiencePoints") + .HasColumnType("int"); + + b.Property("GitHubId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("GitHubUsername") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LastContributionDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("GitHubId") + .IsUnique(); + + b.HasIndex("GitHubUsername") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("GitQuest.Backend.Models.UserContribution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("IssueId") + .HasColumnType("int"); + + b.Property("PullRequestUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("IssueId"); + + b.HasIndex("UserId"); + + b.ToTable("Contributions"); + }); + + modelBuilder.Entity("GitQuest.Backend.Models.Quest", b => + { + b.HasOne("GitQuest.Backend.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GitQuest.Backend.Models.UserContribution", b => + { + b.HasOne("GitQuest.Backend.Models.Issue", "Issue") + .WithMany() + .HasForeignKey("IssueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GitQuest.Backend.Models.User", "User") + .WithMany("Contributions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Issue"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GitQuest.Backend.Models.User", b => + { + b.Navigation("Contributions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Backend/Migrations/20260315125659_AddUniqueConstraints.cs b/Backend/Backend/Migrations/20260315125659_AddUniqueConstraints.cs new file mode 100644 index 0000000..4f15c30 --- /dev/null +++ b/Backend/Backend/Migrations/20260315125659_AddUniqueConstraints.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddUniqueConstraints : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Quests_UserId", + table: "Quests"); + + migrationBuilder.AlterColumn( + name: "GitHubId", + table: "Users", + type: "nvarchar(450)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AlterColumn( + name: "Status", + table: "Quests", + type: "nvarchar(450)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.CreateIndex( + name: "IX_Users_GitHubId", + table: "Users", + column: "GitHubId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Quests_UserId_GitHubIssueId_ActiveStatus", + table: "Quests", + columns: new[] { "UserId", "GitHubIssueId", "Status" }, + unique: true, + filter: "[Status] = 'In Progress'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_GitHubId", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Quests_UserId_GitHubIssueId_ActiveStatus", + table: "Quests"); + + migrationBuilder.AlterColumn( + name: "GitHubId", + table: "Users", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(450)"); + + migrationBuilder.AlterColumn( + name: "Status", + table: "Quests", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(450)"); + + migrationBuilder.CreateIndex( + name: "IX_Quests_UserId", + table: "Quests", + column: "UserId"); + } + } +} diff --git a/Backend/Backend/Migrations/GitQuestContextModelSnapshot.cs b/Backend/Backend/Migrations/GitQuestContextModelSnapshot.cs index 7efdf0e..11123bb 100644 --- a/Backend/Backend/Migrations/GitQuestContextModelSnapshot.cs +++ b/Backend/Backend/Migrations/GitQuestContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.25") + .HasAnnotation("ProductVersion", "8.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -119,14 +119,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("nvarchar(450)"); b.Property("UserId") .HasColumnType("uniqueidentifier"); b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("UserId", "GitHubIssueId", "Status") + .IsUnique() + .HasDatabaseName("IX_Quests_UserId_GitHubIssueId_ActiveStatus") + .HasFilter("[Status] = 'In Progress'"); b.ToTable("Quests"); }); @@ -151,7 +154,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GitHubId") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("nvarchar(450)"); b.Property("GitHubUsername") .IsRequired() @@ -162,6 +165,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("GitHubId") + .IsUnique(); + b.HasIndex("GitHubUsername") .IsUnique(); diff --git a/Backend/Backend/Models/GitQuestContext.cs b/Backend/Backend/Models/GitQuestContext.cs index c050733..f887c68 100644 --- a/Backend/Backend/Models/GitQuestContext.cs +++ b/Backend/Backend/Models/GitQuestContext.cs @@ -15,13 +15,23 @@ public GitQuestContext(DbContextOptions options) : base(options protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Many-to-Many Configuration or specific indexing modelBuilder.Entity() .HasIndex(u => u.GitHubUsername) .IsUnique(); + modelBuilder.Entity() + .HasIndex(u => u.GitHubId) + .IsUnique(); + modelBuilder.Entity() .HasIndex(i => i.GitHubIssueId) .IsUnique(); + + // Prevent duplicate active quests for the same user/issue (TOCTOU protection) + modelBuilder.Entity() + .HasIndex(q => new { q.UserId, q.GitHubIssueId, q.Status }) + .HasFilter("[Status] = 'In Progress'") + .IsUnique() + .HasDatabaseName("IX_Quests_UserId_GitHubIssueId_ActiveStatus"); } } \ No newline at end of file diff --git a/Backend/Backend/Models/GitQuestContextFactory.cs b/Backend/Backend/Models/GitQuestContextFactory.cs new file mode 100644 index 0000000..7059334 --- /dev/null +++ b/Backend/Backend/Models/GitQuestContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace GitQuest.Backend.Models; + +public class GitQuestContextFactory : IDesignTimeDbContextFactory +{ + public GitQuestContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? "Server=(localdb)\\mssqllocaldb;Database=GitQuestDB;Trusted_Connection=True;"; + + optionsBuilder.UseSqlServer(connectionString); + return new GitQuestContext(optionsBuilder.Options); + } +} diff --git a/Backend/Backend/Program.cs b/Backend/Backend/Program.cs index ea1ffa6..cb3098c 100644 --- a/Backend/Backend/Program.cs +++ b/Backend/Backend/Program.cs @@ -49,7 +49,9 @@ // 4. JWT Auth var jwtSettings = builder.Configuration.GetSection("JwtSettings"); -var secretKey = jwtSettings["Key"] ?? "Your_Fallback_Very_Long_Secret_Key_123!"; +var secretKey = jwtSettings["Key"]; +if (string.IsNullOrEmpty(secretKey)) + throw new InvalidOperationException("JwtSettings:Key is not configured. Set it via environment variable or user secrets."); var key = Encoding.ASCII.GetBytes(secretKey); builder.Services.AddAuthentication(options => diff --git a/Backend/Backend/Services/GitHubService.cs b/Backend/Backend/Services/GitHubService.cs index 05f267e..6aa655d 100644 --- a/Backend/Backend/Services/GitHubService.cs +++ b/Backend/Backend/Services/GitHubService.cs @@ -28,13 +28,17 @@ public GitHubService(HttpClient httpClient, IConfiguration config) client_id = section["ClientId"], client_secret = section["ClientSecret"], code = code, - redirect_uri = section["RedirectUri"] // Ensure this matches GitHub settings + redirect_uri = section["RedirectUri"] }; - var response = await _httpClient.PostAsJsonAsync("https://github.com/login/oauth/access_token", payload); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token") + { + Content = JsonContent.Create(payload) + }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); - // GitHub returns this as form-url-encoded by default unless we ask for JSON - _httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; var result = await response.Content.ReadFromJsonAsync(); return result?.AccessToken; @@ -42,7 +46,7 @@ public GitHubService(HttpClient httpClient, IConfiguration config) public async Task GetGitHubUser(string accessToken) { - var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); var response = await _httpClient.SendAsync(request); @@ -71,8 +75,9 @@ public async Task> GetSuggestedIssues(string language) RepoFullName = ExtractRepoName(item.RepositoryUrl), Language = language, IssueUrl = item.HtmlUrl, - Difficulty = item.Labels.Any(l => l.Name.Contains("good first issue")) ? "Beginner" : "Intermediate", - XPReward = item.Labels.Any(l => l.Name.Contains("good first issue")) ? 15 : 30, + Difficulty = item.Labels.Any(l => l.Name.Contains("good first issue", StringComparison.OrdinalIgnoreCase)) ? "Beginner" : "Intermediate", + XPReward = item.Labels.Any(l => + l.Name.Contains("good first issue", StringComparison.OrdinalIgnoreCase)) ? 15 : 30, IsActive = true }).ToList() ?? new List(); } @@ -96,7 +101,7 @@ public record GitHubSearchResponse([property: JsonPropertyName("items")] List Labels diff --git a/Backend/Backend/appsettings.json b/Backend/Backend/appsettings.json index ab7dc68..6df1133 100644 --- a/Backend/Backend/appsettings.json +++ b/Backend/Backend/appsettings.json @@ -10,14 +10,14 @@ "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=GitQuestDB;Trusted_Connection=True;MultipleActiveResultSets=true" }, "JwtSettings": { - "Key": "Your_Super_Secret_Key_At_Least_32_Chars_Long!", + "Key": "", "Issuer": "GitQuestBackend", "Audience": "GitQuestFrontend", "DurationInMinutes": 1440 }, "GitHub": { - "ClientId": "Ov23lifeZIfuvH3zcfQk", - "ClientSecret": "fc0f5479ac298f4adf28d64d11a24f4a8301806b", + "ClientId": "", + "ClientSecret": "", "CallbackUrl": "http://localhost:3000/api/auth/callback/github" } } \ No newline at end of file