diff --git a/Backend/Backend/Backend.csproj b/Backend/Backend/Backend.csproj
index f6a33a8..9ae8f89 100644
--- a/Backend/Backend/Backend.csproj
+++ b/Backend/Backend/Backend.csproj
@@ -7,7 +7,19 @@
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
diff --git a/Backend/Backend/Controllers/AuthController.cs b/Backend/Backend/Controllers/AuthController.cs
new file mode 100644
index 0000000..883a247
--- /dev/null
+++ b/Backend/Backend/Controllers/AuthController.cs
@@ -0,0 +1,157 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.IdentityModel.Tokens;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using GitQuest.Backend.Models;
+using Microsoft.EntityFrameworkCore;
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace GitQuest.Backend.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class AuthController : ControllerBase
+{
+ private readonly IConfiguration _config;
+ private readonly GitQuestContext _context;
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public AuthController(IConfiguration config, GitQuestContext context, IHttpClientFactory httpClientFactory)
+ {
+ _config = config;
+ _context = context;
+ _httpClientFactory = httpClientFactory;
+ }
+
+ [HttpPost("github-login")]
+ public async Task GitHubLogin([FromBody] string code)
+ {
+ // 1. Exchange Code for GitHub Access Token
+ var githubToken = await GetGitHubAccessToken(code);
+ if (string.IsNullOrEmpty(githubToken)) return Unauthorized("Invalid GitHub Code");
+
+ // 2. Get User Info from GitHub
+ var githubUser = await GetGitHubUser(githubToken);
+ if (githubUser is null) return BadRequest("Could not fetch GitHub profile");
+
+ // 3. Sync with Database
+ var user = await _context.Users.FirstOrDefaultAsync(u => u.GitHubId == githubUser.id.ToString());
+
+ if (user == null)
+ {
+ user = new User
+ {
+ Id = Guid.NewGuid(),
+ GitHubId = githubUser.id.ToString(),
+ GitHubUsername = githubUser.login,
+ AvatarUrl = githubUser.avatar_url,
+ ExperiencePoints = 0
+ };
+ _context.Users.Add(user);
+ }
+ else
+ {
+ user.GitHubUsername = githubUser.login;
+ user.AvatarUrl = githubUser.avatar_url;
+ }
+
+ await _context.SaveChangesAsync();
+
+ // 4. Generate our own JWT
+ var token = GenerateJwtToken(user);
+
+ return Ok(new { token, user });
+ }
+
+ private async Task GetGitHubAccessToken(string code)
+ {
+ var client = _httpClientFactory.CreateClient();
+ var requestData = new Dictionary
+ {
+ { "client_id", _config["GitHub:ClientId"]! },
+ { "client_secret", _config["GitHub:ClientSecret"]! },
+ { "code", code },
+ { "redirect_uri", _config["GitHub:CallbackUrl"] ?? "" }
+ };
+
+ 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"));
+
+ try
+ {
+ var response = await client.SendAsync(request);
+ if (!response.IsSuccessStatusCode) return null;
+ var result = await response.Content.ReadFromJsonAsync();
+ return result?.access_token;
+ }
+ catch (HttpRequestException)
+ {
+ return null;
+ }
+ catch (TaskCanceledException)
+ {
+ return null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private async Task GetGitHubUser(string token)
+ {
+ var client = _httpClientFactory.CreateClient();
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("GitQuest-App");
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ try
+ {
+ var response = await client.GetAsync("https://api.github.com/user");
+ if (!response.IsSuccessStatusCode) return null;
+ return await response.Content.ReadFromJsonAsync();
+ }
+ catch (HttpRequestException)
+ {
+ return null;
+ }
+ catch (TaskCanceledException)
+ {
+ return null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private string GenerateJwtToken(User user)
+ {
+ var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["JwtSettings:Key"]!));
+ var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
+
+ var claims = new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
+ new Claim(ClaimTypes.Name, user.GitHubUsername),
+ new Claim("avatar", user.AvatarUrl ?? "")
+ };
+
+ var token = new JwtSecurityToken(
+ issuer: _config["JwtSettings:Issuer"],
+ audience: _config["JwtSettings:Audience"],
+ claims: claims,
+ expires: DateTime.Now.AddMinutes(double.Parse(_config["JwtSettings:DurationInMinutes"] ?? "1440")),
+ signingCredentials: credentials);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+}
+
+public record GitHubTokenResponse(string access_token);
+
+public record GitHubUserResponse(long id, string login, string avatar_url);
\ No newline at end of file
diff --git a/Backend/Backend/Controllers/IssuesController.cs b/Backend/Backend/Controllers/IssuesController.cs
index f335832..64c38d0 100644
--- a/Backend/Backend/Controllers/IssuesController.cs
+++ b/Backend/Backend/Controllers/IssuesController.cs
@@ -1,11 +1,146 @@
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
+using System.Security.Claims;
+using GitQuest.Backend.Services;
+using GitQuest.Backend.Models;
-namespace Backend.Controllers
+namespace GitQuest.Backend.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+// This forces the controller to use the JWT scheme we defined in Program.cs
+[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
+public class IssuesController : ControllerBase
{
- [Route("api/[controller]")]
- [ApiController]
- public class IssuesController : ControllerBase
+ private readonly GitHubService _githubService;
+ private readonly GitQuestContext _context;
+
+ public IssuesController(GitHubService githubService, GitQuestContext context)
+ {
+ _githubService = githubService;
+ _context = context;
+ }
+
+ [HttpGet("discover")]
+ [AllowAnonymous]
+ public async Task GetIssues([FromQuery] string language = "typescript")
+ {
+ var issues = await _githubService.GetSuggestedIssues(language);
+ if (issues == null) return BadRequest("Could not fetch issues.");
+ return Ok(issues);
+ }
+
+ [HttpPost("{id}/claim")]
+ public async Task ClaimQuest(long id)
{
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized("User ID missing from token.");
+
+ if (!Guid.TryParse(userIdClaim, out var userId))
+ return Unauthorized("Invalid user ID in token.");
+
+ // 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 Conflict("Quest already active.");
+
+ var quest = new Quest
+ {
+ Id = Guid.NewGuid(),
+ UserId = userId,
+ GitHubIssueId = id,
+ Status = "In Progress",
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.Quests.Add(quest);
+ 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 });
+ }
+
+ [HttpGet("my-active-quests")]
+ public async Task GetMyQuests()
+ {
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (userIdClaim == null) return Unauthorized("User ID missing from token.");
+
+ if (!Guid.TryParse(userIdClaim, out var userId))
+ return Unauthorized("Invalid user ID in token.");
+ var quests = await _context.Quests
+ .Where(q => q.UserId == userId && q.Status == "In Progress")
+ .ToListAsync();
+ return Ok(quests);
+ }
+
+ [HttpPost("{id}/submit")]
+ public async Task SubmitQuest(long id)
+ {
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (userIdClaim == null) return Unauthorized("User ID missing from token.");
+ if (!Guid.TryParse(userIdClaim, out var userId))
+ return Unauthorized("Invalid user ID in token.");
+
+ var quest = await _context.Quests
+ .FirstOrDefaultAsync(q => q.UserId == userId && q.GitHubIssueId == id && q.Status == "In Progress");
+
+ 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)
+ return NotFound("User account not found.");
+
+ 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 = $"{issue.XPReward} XP Awarded!", totalXp = user.ExperiencePoints });
}
-}
+}
\ No newline at end of file
diff --git a/Backend/Backend/Controllers/ProjectsController.cs b/Backend/Backend/Controllers/ProjectsController.cs
index 5b8ee30..04518d0 100644
--- a/Backend/Backend/Controllers/ProjectsController.cs
+++ b/Backend/Backend/Controllers/ProjectsController.cs
@@ -1,11 +1,29 @@
-using Microsoft.AspNetCore.Http;
+
+using GitQuest.Backend.Models;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
-namespace Backend.Controllers
+namespace GitQuest.Backend.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class ProjectsController : ControllerBase
{
- [Route("api/[controller]")]
- [ApiController]
- public class ProjectsController : ControllerBase
+ private readonly GitQuestContext _context;
+
+ public ProjectsController(GitQuestContext context) => _context = context;
+
+ [HttpGet]
+ public async Task GetProjects()
+ {
+ return Ok(await _context.Projects.ToListAsync());
+ }
+
+ [HttpGet("{id}")]
+ public async Task GetProject(int id)
{
+ var project = await _context.Projects.FindAsync(id);
+ if (project == null) return NotFound();
+ return Ok(project);
}
-}
+}
\ No newline at end of file
diff --git a/Backend/Backend/Controllers/UsersController.cs b/Backend/Backend/Controllers/UsersController.cs
index d86004e..cc340ba 100644
--- a/Backend/Backend/Controllers/UsersController.cs
+++ b/Backend/Backend/Controllers/UsersController.cs
@@ -1,11 +1,64 @@
-using Microsoft.AspNetCore.Http;
+using GitQuest.Backend.Models;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
-namespace Backend.Controllers
+namespace GitQuest.Backend.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class UsersController : ControllerBase
{
- [Route("api/[controller]")]
- [ApiController]
- public class UsersController : ControllerBase
+ private readonly GitQuestContext _context;
+
+ public UsersController(GitQuestContext context) => _context = context;
+
+ // GET: api/users/leaderboard
+ [HttpGet("leaderboard")]
+ public async Task GetLeaderboard()
+ {
+ var topUsers = await _context.Users
+ .OrderByDescending(u => u.ExperiencePoints)
+ .Take(10)
+ .Select(u => new { u.GitHubUsername, u.AvatarUrl, u.ExperiencePoints, u.CurrentStreak })
+ .ToListAsync();
+
+ return Ok(topUsers);
+ }
+
+ // GET: api/users/profile/{username}
+ [HttpGet("profile/{username}")]
+ public async Task GetProfile(string username)
{
+ var user = await _context.Users
+ .Include(u => u.Contributions)
+ .ThenInclude(c => c.Issue)
+ .FirstOrDefaultAsync(u => u.GitHubUsername == username);
+
+ if (user == null) return NotFound();
+
+ 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/20260315082912_InitialCreate.Designer.cs b/Backend/Backend/Migrations/20260315082912_InitialCreate.Designer.cs
new file mode 100644
index 0000000..5d41354
--- /dev/null
+++ b/Backend/Backend/Migrations/20260315082912_InitialCreate.Designer.cs
@@ -0,0 +1,201 @@
+//
+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("20260315082912_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.25")
+ .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.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.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/20260315082912_InitialCreate.cs b/Backend/Backend/Migrations/20260315082912_InitialCreate.cs
new file mode 100644
index 0000000..9b406ba
--- /dev/null
+++ b/Backend/Backend/Migrations/20260315082912_InitialCreate.cs
@@ -0,0 +1,143 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Backend.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Issues",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ GitHubIssueId = table.Column(type: "bigint", nullable: false),
+ Title = table.Column(type: "nvarchar(max)", nullable: false),
+ Description = table.Column(type: "nvarchar(max)", nullable: true),
+ RepoFullName = table.Column(type: "nvarchar(max)", nullable: false),
+ Language = table.Column(type: "nvarchar(max)", nullable: true),
+ Difficulty = table.Column(type: "nvarchar(max)", nullable: false),
+ XPReward = table.Column(type: "int", nullable: false),
+ IssueUrl = table.Column(type: "nvarchar(max)", nullable: false),
+ IsActive = table.Column(type: "bit", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Issues", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Projects",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(max)", nullable: false),
+ Description = table.Column(type: "nvarchar(max)", nullable: true),
+ HtmlUrl = table.Column(type: "nvarchar(max)", nullable: false),
+ OwnerAvatarUrl = table.Column(type: "nvarchar(max)", nullable: true),
+ Stars = table.Column(type: "int", nullable: false),
+ TechStackSummary = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Projects", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ GitHubId = table.Column(type: "nvarchar(450)", nullable: false),
+ GitHubUsername = table.Column(type: "nvarchar(450)", nullable: false),
+ Email = table.Column(type: "nvarchar(max)", nullable: true),
+ AvatarUrl = table.Column(type: "nvarchar(max)", nullable: true),
+ ExperiencePoints = table.Column(type: "int", nullable: false),
+ CurrentStreak = table.Column(type: "int", nullable: false),
+ LastContributionDate = table.Column(type: "datetime2", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Contributions",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ UserId = table.Column(type: "uniqueidentifier", nullable: false),
+ IssueId = table.Column(type: "int", nullable: false),
+ CompletedAt = table.Column(type: "datetime2", nullable: false),
+ PullRequestUrl = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Contributions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Contributions_Issues_IssueId",
+ column: x => x.IssueId,
+ principalTable: "Issues",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_Contributions_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Contributions_IssueId",
+ table: "Contributions",
+ column: "IssueId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Contributions_UserId",
+ table: "Contributions",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Issues_GitHubIssueId",
+ table: "Issues",
+ column: "GitHubIssueId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Users_GitHubId",
+ table: "Users",
+ column: "GitHubId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Users_GitHubUsername",
+ table: "Users",
+ column: "GitHubUsername",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Contributions");
+
+ migrationBuilder.DropTable(
+ name: "Projects");
+
+ migrationBuilder.DropTable(
+ name: "Issues");
+
+ migrationBuilder.DropTable(
+ name: "Users");
+ }
+ }
+}
diff --git a/Backend/Backend/Migrations/20260315104521_AddQuestTable.Designer.cs b/Backend/Backend/Migrations/20260315104521_AddQuestTable.Designer.cs
new file mode 100644
index 0000000..3fc5096
--- /dev/null
+++ b/Backend/Backend/Migrations/20260315104521_AddQuestTable.Designer.cs
@@ -0,0 +1,238 @@
+//
+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("20260315104521_AddQuestTable")]
+ partial class AddQuestTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.25")
+ .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(max)");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ 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(max)");
+
+ b.Property("GitHubUsername")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LastContributionDate")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ 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/20260315104521_AddQuestTable.cs b/Backend/Backend/Migrations/20260315104521_AddQuestTable.cs
new file mode 100644
index 0000000..a00bfb4
--- /dev/null
+++ b/Backend/Backend/Migrations/20260315104521_AddQuestTable.cs
@@ -0,0 +1,49 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Backend.Migrations
+{
+ ///
+ public partial class AddQuestTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Quests",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ UserId = table.Column(type: "uniqueidentifier", nullable: false),
+ GitHubIssueId = table.Column(type: "bigint", nullable: false),
+ Status = table.Column(type: "nvarchar(max)", nullable: false),
+ CreatedAt = table.Column(type: "datetime2", nullable: false),
+ CompletedAt = table.Column(type: "datetime2", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Quests", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Quests_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Quests_UserId",
+ table: "Quests",
+ column: "UserId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Quests");
+ }
+ }
+}
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..687b757
--- /dev/null
+++ b/Backend/Backend/Migrations/20260315125659_AddUniqueConstraints.cs
@@ -0,0 +1,54 @@
+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: "Status",
+ table: "Quests",
+ type: "nvarchar(450)",
+ nullable: false,
+ oldClrType: typeof(string),
+ oldType: "nvarchar(max)");
+
+ 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_Quests_UserId_GitHubIssueId_ActiveStatus",
+ table: "Quests");
+
+ 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
new file mode 100644
index 0000000..11123bb
--- /dev/null
+++ b/Backend/Backend/Migrations/GitQuestContextModelSnapshot.cs
@@ -0,0 +1,241 @@
+//
+using System;
+using GitQuest.Backend.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Backend.Migrations
+{
+ [DbContext(typeof(GitQuestContext))]
+ partial class GitQuestContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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/Models/Contribution.cs b/Backend/Backend/Models/Contribution.cs
new file mode 100644
index 0000000..9100c96
--- /dev/null
+++ b/Backend/Backend/Models/Contribution.cs
@@ -0,0 +1,16 @@
+namespace GitQuest.Backend.Models;
+
+public class UserContribution
+{
+ public Guid Id { get; set; }
+
+ public Guid UserId { get; set; }
+ public User User { get; set; } = null!;
+
+ public int IssueId { get; set; }
+ public Issue Issue { get; set; } = null!;
+
+ public DateTime CompletedAt { get; set; } = DateTime.UtcNow;
+
+ public string? PullRequestUrl { get; set; } // Proof of work
+}
\ No newline at end of file
diff --git a/Backend/Backend/Models/GitQuestContext.cs b/Backend/Backend/Models/GitQuestContext.cs
index 3af5d28..f887c68 100644
--- a/Backend/Backend/Models/GitQuestContext.cs
+++ b/Backend/Backend/Models/GitQuestContext.cs
@@ -1,6 +1,37 @@
-namespace Backend.Models
+using Microsoft.EntityFrameworkCore;
+using System.Reflection.Emit;
+
+namespace GitQuest.Backend.Models;
+
+public class GitQuestContext : DbContext
{
- public class GitQuestContext
+ public GitQuestContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Users { get; set; }
+ public DbSet Issues { get; set; }
+ public DbSet Projects { get; set; }
+ public DbSet Contributions { get; set; }
+ public DbSet Quests { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
{
+ 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/Models/Issue.cs b/Backend/Backend/Models/Issue.cs
index 3f7c1bd..44dcaed 100644
--- a/Backend/Backend/Models/Issue.cs
+++ b/Backend/Backend/Models/Issue.cs
@@ -1,6 +1,30 @@
-namespace Backend.Models
+using System.ComponentModel.DataAnnotations;
+
+namespace GitQuest.Backend.Models;
+
+public class Issue
{
- public class Issue
- {
- }
-}
+ [Key]
+ public int Id { get; set; }
+
+ [Required]
+ public long GitHubIssueId { get; set; } // External ID from GitHub API
+
+ [Required]
+ public string Title { get; set; } = string.Empty;
+
+ public string? Description { get; set; }
+
+ [Required]
+ public string RepoFullName { get; set; } = string.Empty; // e.g., "dotnet/aspnetcore"
+
+ public string? Language { get; set; }
+
+ public string Difficulty { get; set; } = "Beginner"; // Beginner, Intermediate, Expert
+
+ public int XPReward { get; set; } = 10;
+
+ public string IssueUrl { get; set; } = string.Empty;
+
+ public bool IsActive { get; set; } = true;
+}
\ No newline at end of file
diff --git a/Backend/Backend/Models/Project.cs b/Backend/Backend/Models/Project.cs
index abd3518..a6cee93 100644
--- a/Backend/Backend/Models/Project.cs
+++ b/Backend/Backend/Models/Project.cs
@@ -1,6 +1,23 @@
-namespace Backend.Models
+using System.ComponentModel.DataAnnotations;
+
+namespace GitQuest.Backend.Models;
+
+public class Project
{
- public class Project
- {
- }
-}
+ [Key]
+ public int Id { get; set; }
+
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ public string? Description { get; set; }
+
+ public string HtmlUrl { get; set; } = string.Empty;
+
+ public string? OwnerAvatarUrl { get; set; }
+
+ public int Stars { get; set; }
+
+ // AI analysis of the project's health or tech stack
+ public string? TechStackSummary { get; set; }
+}
\ No newline at end of file
diff --git a/Backend/Backend/Models/Quest.cs b/Backend/Backend/Models/Quest.cs
new file mode 100644
index 0000000..d607c15
--- /dev/null
+++ b/Backend/Backend/Models/Quest.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace GitQuest.Backend.Models;
+
+public class Quest
+{
+ [Key]
+ public Guid Id { get; set; }
+
+ [Required]
+ public Guid UserId { get; set; }
+
+ [Required]
+ public long GitHubIssueId { get; set; } // Links to the Issue.GitHubIssueId
+
+ [Required]
+ public string Status { get; set; } = "In Progress"; // In Progress, Completed, Abandoned
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public DateTime? CompletedAt { get; set; }
+
+ // Navigation properties (Optional, but helpful for EF Core)
+ [ForeignKey("UserId")]
+ public User? User { get; set; }
+}
\ No newline at end of file
diff --git a/Backend/Backend/Models/User.cs b/Backend/Backend/Models/User.cs
index 5779711..35f7ff1 100644
--- a/Backend/Backend/Models/User.cs
+++ b/Backend/Backend/Models/User.cs
@@ -1,6 +1,26 @@
-namespace Backend.Models
+using System.ComponentModel.DataAnnotations;
+
+namespace GitQuest.Backend.Models;
+
+public class User
{
- public class User
- {
- }
-}
+ [Key]
+ public Guid Id { get; set; }
+
+ [Required]
+ public string GitHubId { get; set; } = string.Empty; // Unique ID from GitHub (e.g., "823456")
+
+ [Required]
+ public string GitHubUsername { get; set; } = string.Empty;
+
+ public string? Email { get; set; }
+
+ public string? AvatarUrl { get; set; }
+
+ // Gamification Stats
+ public int ExperiencePoints { get; set; } = 0;
+ public int CurrentStreak { get; set; } = 0;
+ public DateTime? LastContributionDate { get; set; }
+
+ public ICollection Contributions { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/Backend/Backend/Program.cs b/Backend/Backend/Program.cs
index 48863a6..28761fa 100644
--- a/Backend/Backend/Program.cs
+++ b/Backend/Backend/Program.cs
@@ -1,15 +1,97 @@
-var builder = WebApplication.CreateBuilder(args);
+using System.Text;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.OpenApi.Models;
+using GitQuest.Backend.Models;
+using GitQuest.Backend.Services;
-// Add services to the container.
+var builder = WebApplication.CreateBuilder(args);
+// 1. Add Services
builder.Services.AddControllers();
-// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen();
+
+// Enhanced Swagger with correct Bearer Token UI
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo { Title = "GitQuest API", Version = "v1" });
+
+ c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
+ {
+ Name = "Authorization",
+ Type = SecuritySchemeType.Http, // Changed to Http for better Swagger UI integration
+ Scheme = "Bearer",
+ BearerFormat = "JWT",
+ In = ParameterLocation.Header,
+ Description = "Enter your JWT token (no need to type 'Bearer ' manually in this box)"
+ });
+
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
+ },
+ Array.Empty()
+ }
+ });
+});
+
+// 2. Database
+builder.Services.AddDbContext(options =>
+ options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
+
+// 3. Infrastructure
+builder.Services.AddHttpClient();
+builder.Services.AddScoped();
+
+// 4. JWT Auth
+var jwtSettings = builder.Configuration.GetSection("JwtSettings");
+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.UTF8.GetBytes(secretKey);
+
+builder.Services.AddAuthentication(options =>
+{
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+})
+.AddJwtBearer(options =>
+{
+ options.RequireHttpsMetadata = false; // Set to true in production
+ options.SaveToken = true;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(key),
+ ValidateIssuer = true, // Ensure this matches your AuthService
+ ValidIssuer = jwtSettings["Issuer"],
+ ValidateAudience = true, // Ensure this matches your AuthService
+ ValidAudience = jwtSettings["Audience"],
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.Zero // Removes the default 5-minute grace period
+ };
+});
+
+builder.Services.AddAuthorization();
+
+// 5. CORS
+builder.Services.AddCors(options =>
+{
+ options.AddPolicy("GitQuestPolicy", policy =>
+ {
+ policy.WithOrigins("http://localhost:3000")
+ .AllowAnyHeader()
+ .AllowAnyMethod();
+ });
+});
var app = builder.Build();
-// Configure the HTTP request pipeline.
+// 6. Pipeline - CRITICAL ORDER
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
@@ -17,9 +99,12 @@
}
app.UseHttpsRedirection();
+app.UseCors("GitQuestPolicy");
+// Authentication MUST come before Authorization
+app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
-app.Run();
+app.Run();
\ No newline at end of file
diff --git a/Backend/Backend/Services/GitHubService.cs b/Backend/Backend/Services/GitHubService.cs
index b7a030c..ad63ca8 100644
--- a/Backend/Backend/Services/GitHubService.cs
+++ b/Backend/Backend/Services/GitHubService.cs
@@ -1,6 +1,110 @@
-namespace Backend.Services
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using GitQuest.Backend.Models;
+using Microsoft.Extensions.Configuration;
+
+namespace GitQuest.Backend.Services;
+
+public class GitHubService
{
- public interface GitHubService
+ private readonly HttpClient _httpClient;
+ private readonly IConfiguration _config;
+
+ public GitHubService(HttpClient httpClient, IConfiguration config)
{
+ _httpClient = httpClient;
+ _config = config;
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "GitQuest-API");
}
+
+ // --- NEW: OAuth Methods ---
+
+ public async Task GetAccessToken(string code)
+ {
+ var section = _config.GetSection("GitHub");
+
+ var payload = new
+ {
+ client_id = section["ClientId"],
+ client_secret = section["ClientSecret"],
+ code = code,
+ redirect_uri = section["CallbackUrl"]
+ };
+
+ 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"));
+
+ var response = await _httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode) return null;
+
+ var result = await response.Content.ReadFromJsonAsync();
+ return result?.AccessToken;
+ }
+
+ public async Task GetGitHubUser(string accessToken)
+ {
+ 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);
+ if (!response.IsSuccessStatusCode) return null;
+
+ return await response.Content.ReadFromJsonAsync();
+ }
+
+ // --- Existing: Issue Discovery ---
+
+ public async Task> GetSuggestedIssues(string language)
+ {
+ var query = $"language:{language}+state:open+label:\"good first issue\",\"help wanted\"";
+ var url = $"https://api.github.com/search/issues?q={query}&sort=created&order=desc";
+
+ var response = await _httpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode) return new List();
+
+ var data = await response.Content.ReadFromJsonAsync();
+
+ return data?.Items.Select(item => new Issue
+ {
+ GitHubIssueId = item.Id,
+ Title = item.Title,
+ Description = item.Body,
+ RepoFullName = ExtractRepoName(item.RepositoryUrl),
+ Language = language,
+ IssueUrl = item.HtmlUrl,
+ 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();
+ }
+
+ private string ExtractRepoName(string url) => url.Replace("https://api.github.com/repos/", "");
}
+
+// --- DTOs ---
+
+public record GitHubTokenResponse([property: JsonPropertyName("access_token")] string AccessToken);
+
+public record GitHubUserResponse(
+ [property: JsonPropertyName("id")] long Id,
+ [property: JsonPropertyName("login")] string Login,
+ [property: JsonPropertyName("avatar_url")] string AvatarUrl,
+ [property: JsonPropertyName("email")] string? Email
+);
+
+public record GitHubSearchResponse([property: JsonPropertyName("items")] List Items);
+
+public record GitHubIssueItem(
+ [property: JsonPropertyName("id")] long Id,
+ [property: JsonPropertyName("title")] string Title,
+ [property: JsonPropertyName("body")] string? Body,
+ [property: JsonPropertyName("html_url")] string HtmlUrl,
+ [property: JsonPropertyName("repository_url")] string RepositoryUrl,
+ [property: JsonPropertyName("labels")] List Labels
+);
+
+public record GitHubLabel([property: JsonPropertyName("name")] string Name);
\ No newline at end of file
diff --git a/Backend/Backend/appsettings.json b/Backend/Backend/appsettings.json
index 10f68b8..6df1133 100644
--- a/Backend/Backend/appsettings.json
+++ b/Backend/Backend/appsettings.json
@@ -5,5 +5,19 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
-}
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=GitQuestDB;Trusted_Connection=True;MultipleActiveResultSets=true"
+ },
+ "JwtSettings": {
+ "Key": "",
+ "Issuer": "GitQuestBackend",
+ "Audience": "GitQuestFrontend",
+ "DurationInMinutes": 1440
+ },
+ "GitHub": {
+ "ClientId": "",
+ "ClientSecret": "",
+ "CallbackUrl": "http://localhost:3000/api/auth/callback/github"
+ }
+}
\ No newline at end of file