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