Skip to content
Merged
12 changes: 12 additions & 0 deletions Backend/Backend/Backend.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.13">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.13" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>

<ItemGroup>
<Folder Include="NewFolder\" />
</ItemGroup>

</Project>
157 changes: 157 additions & 0 deletions Backend/Backend/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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<string?> GetGitHubAccessToken(string code)
{
var client = _httpClientFactory.CreateClient();
var requestData = new Dictionary<string, string>
{
{ "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<GitHubTokenResponse>();
return result?.access_token;
}
catch (HttpRequestException)
{
return null;
}
catch (TaskCanceledException)
{
return null;
}
catch (JsonException)
{
return null;
}
}

private async Task<GitHubUserResponse?> 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<GitHubUserResponse>();
}
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);
149 changes: 142 additions & 7 deletions Backend/Backend/Controllers/IssuesController.cs
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
}
30 changes: 24 additions & 6 deletions Backend/Backend/Controllers/ProjectsController.cs
Original file line number Diff line number Diff line change
@@ -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<IActionResult> GetProjects()
{
return Ok(await _context.Projects.ToListAsync());
}

[HttpGet("{id}")]
public async Task<IActionResult> GetProject(int id)
{
var project = await _context.Projects.FindAsync(id);
if (project == null) return NotFound();
return Ok(project);
}
}
}
Loading
Loading