Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1b6a902
feat(api): add request records
SubochArtem Apr 4, 2026
45d05b9
feat(api): add authorization configuration
SubochArtem Apr 4, 2026
a4d85d6
feat(api): add ResultFilter
SubochArtem Apr 4, 2026
1f018b1
feat(api): add ExceptionHandlerMiddleware
SubochArtem Apr 4, 2026
679b4f8
feat(api): add ClaimsPrincipalExtensions
SubochArtem Apr 4, 2026
7c7f1d5
feat(api): add admin controolers for cities polls ideas
SubochArtem Apr 5, 2026
e2f17e5
feat(api): add new permisioins for polls and ideas
SubochArtem Apr 5, 2026
a7389bb
refactor(api): update formating in admin controllers
SubochArtem Apr 5, 2026
3ba077e
feat(api): add change status requests to all entities
SubochArtem Apr 6, 2026
3c2682e
feat(application): add Authority to Auth0Settings
SubochArtem Apr 9, 2026
43813c0
refactor(api): use required properties in request DTOs to prevent und…
SubochArtem Apr 9, 2026
ee1dea7
refactor(api): update formating in admin controllers
SubochArtem Apr 9, 2026
3793fc7
fix(api): update ResultFilter to return list of errors
SubochArtem Apr 9, 2026
738ed89
fix(application): use ICommand instead of IRequest<Result<Unit>> in C…
SubochArtem Apr 8, 2026
c7c7c84
fix(infrastructure): replace unsupported Include expression with Wher…
SubochArtem Apr 6, 2026
e820f85
fix(infrastructure): replace unsupported Include expression with Wher…
SubochArtem Apr 6, 2026
8e20771
refactor(domain): update formating in Error
SubochArtem Apr 6, 2026
d1e1a40
refactor(domain): update formating in Error
SubochArtem Apr 6, 2026
35a6432
fix(application): use ICommand instead of IRequest<Result<Unit>> in C…
SubochArtem Apr 8, 2026
b311bfd
Merge branch 'feature/polls-cqrs-handlers-ideas' into task/polls-admi…
SubochArtem Apr 11, 2026
a9ad5f2
refactor(api): remove named overloading on Configure method from Conf…
SubochArtem Apr 16, 2026
ad2405e
refactor(api): update formating in ResultFilter
SubochArtem Apr 16, 2026
722b068
refactor(api): update formating in Auth0Settings
SubochArtem Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Polls.API.Authorization;

public class Auth0Settings
{
public required string Domain { get; set; }
public required string Authority { get; set; }
public required string Audience { get; set; }
public required string ClientId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;

namespace Polls.API.Authorization;

public class ConfigureJwtBearerOptions(
IOptions<Auth0Settings> auth0Settings) : IConfigureOptions<JwtBearerOptions>
{
private readonly Auth0Settings _auth0Settings = auth0Settings.Value;

public void Configure(JwtBearerOptions options)
{
options.Authority = _auth0Settings.Authority;
options.Audience = _auth0Settings.Audience;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Authorization;
using Polls.Domain.Authorization;

namespace Polls.API.Authorization;

public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim(Permissions.ClaimType, policyName)
.Build();

return Task.FromResult<AuthorizationPolicy?>(policy);
Comment on lines +10 to +15
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no async work .Using async without await would create a state machine unnecessarily. Task.FromResult returns an already-completed task with zero overhead

}

public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
{
return Task.FromResult(
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
}

public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
{
return Task.FromResult<AuthorizationPolicy?>(null);
}
Comment thread
SubochArtem marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Security.Claims;

namespace Polls.API.Common.Extensions;

public static class ClaimsPrincipalExtensions
{
private const string CityIdClaim = "https://citypulse.com/city_id";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not store it in appsettings?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I preferred a constant over appsettings in this situation because:

  1. This data is consistent across all environments and doesn't need to be configurable.
  2. This specific claim key is only used within this extension class, so it doesn't need global visibility.
  3. ClaimsPrincipalExtensions is a static class, which makes it difficult to use the Options pattern or IConfiguration


public static Guid GetUserId(this ClaimsPrincipal user)
{
var claim = user.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(claim, out var id)
? id
: Guid.Empty;
}

public static Guid GetCityId(this ClaimsPrincipal user)
{
var claim = user.FindFirstValue(CityIdClaim);
return Guid.TryParse(claim, out var id)
? id
: Guid.Empty;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Polls.Domain.Common;
using Polls.Domain.Common.Enums;

namespace Polls.API.Common.Filters;

public class ResultFilter : IActionFilter
{
private const string ErrorsExtensionKey = "errors";

public void OnActionExecuting(ActionExecutingContext context) { }

public void OnActionExecuted(ActionExecutedContext context)
{
if (context.Result is not ObjectResult { Value: Result { IsSuccess: false } result })
return;

var statusCode = GetStatusCode(result.Error.Type);

var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = result.Error.Type.ToString(),
Detail = result.Error.Description,
Instance = context.HttpContext.Request.Path
};

if (result.Errors.Count > 1)
problemDetails.Extensions[ErrorsExtensionKey] = result.Errors
.Select(e => e.Description)
.ToArray();

context.Result = new ObjectResult(problemDetails)
{
StatusCode = statusCode
};
}

private static int GetStatusCode(ErrorType type) => type switch
{
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
ErrorType.Validation => StatusCodes.Status400BadRequest,

_=> StatusCodes.Status400BadRequest
Comment thread
SubochArtem marked this conversation as resolved.
};
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why using both result pattern and ExceptionMiddleware?
Every Internal Exception will be thrown without it

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use Result pattern for expected domain logic. The ExceptionMiddleware is there because I don't want to leak internal error details to user. It allows me to control the output, and ensure every crash is logged in a standardized format

Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Mvc;

namespace Polls.API.Common.Middleware;

public class ExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlerMiddleware> logger)
{
private const string ContentType = "application/json";
private const string InternalServerErrorTitle = "Internal Server Error";
private const string InternalServerErrorDetail = "An unexpected error occurred.";

public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
logger.LogError(
ex,
"Unhandled exception at {Method} {Path}",
context.Request.Method,
context.Request.Path);

await HandleExceptionAsync(context);
}
}

private static async Task HandleExceptionAsync(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = ContentType;

var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = InternalServerErrorTitle,
Detail = InternalServerErrorDetail,
Instance = context.Request.Path
};

await context.Response.WriteAsJsonAsync(problemDetails);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Polls.API.Requests.Cities;
using Polls.Application.Cities.Commands.ChangeStatus;
using Polls.Application.Cities.Commands.CreateCity;
using Polls.Application.Cities.Commands.DeleteCity;
using Polls.Application.Cities.Commands.UpdateCity;
using Polls.Application.Cities.DTOs;
using Polls.Application.Cities.Queries.GetCities;
using Polls.Application.Cities.Queries.GetCityById;
using Polls.Application.Cities.Queries.GetCityWithPolls;
using Polls.Application.Common.Models;
using Polls.Domain.Authorization;
using Polls.Domain.Common;

namespace Polls.API.Controllers.Admin;

[ApiController]
[Route("api/v1/admin/cities")]
[Authorize]
public class AdminCitiesController(ISender sender) : ControllerBase
{
[HttpGet]
[Authorize(Policy = Permissions.Cities.ReadAny)]
public async Task<Result<PagedList<CityDto>>> GetCities(
[FromQuery] CityFilter filter,
CancellationToken cancellationToken)
{
var query = new GetCitiesQuery(
Filter: filter,
IncludeOnlyActive: false);

return await sender.Send(query, cancellationToken);
}

[HttpGet("{id:guid}")]
[Authorize(Policy = Permissions.Cities.ReadAny)]
public async Task<Result<CityDto>> GetCityById(
Guid id,
CancellationToken cancellationToken)
{
var query = new GetCityByIdQuery(
Id: id,
IncludeOnlyActive: false);

return await sender.Send(query, cancellationToken);
}

[HttpGet("{id:guid}/polls")]
[Authorize(Policy = Permissions.Cities.ReadAny)]
public async Task<Result<CityWithPollsDto>> GetCityWithPolls(
Guid id,
CancellationToken cancellationToken)
{
var query = new GetCityWithPollsQuery(
Id: id,
IncludeOnlyActive: false);

return await sender.Send(query, cancellationToken);
}

[HttpPost]
[Authorize(Policy = Permissions.Cities.CreateAny)]
public async Task<Result<CityDto>> CreateCity(
CreateCityRequest request,
CancellationToken cancellationToken)
{
var command = new CreateCityCommand(
Title: request.Title,
Coordinates: request.Coordinates,
Description: request.Description);

return await sender.Send(command, cancellationToken);
}

[HttpPut("{id:guid}")]
[Authorize(Policy = Permissions.Cities.UpdateAny)]
public async Task<Result<CityDto>> UpdateCity(
Guid id,
UpdateCityRequest request,
CancellationToken cancellationToken)
{
var command = new UpdateCityCommand(
Id: id,
Title: request.Title,
Coordinates: request.Coordinates,
Description: request.Description);

return await sender.Send(command, cancellationToken);
}

[HttpDelete("{id:guid}")]
[Authorize(Policy = Permissions.Cities.DeleteAny)]
public async Task<Result<Unit>> DeleteCity(
Guid id,
CancellationToken cancellationToken)
{
var command = new DeleteCityCommand(
Id: id);

return await sender.Send(command, cancellationToken);
}

[HttpPatch("{id:guid}/status")]
[Authorize(Policy = Permissions.Cities.ChangeStatusAny)]
public async Task<Result<Unit>> ChangeStatus(
Guid id,
ChangeCityStatusRequest request,
CancellationToken cancellationToken)
{
var command = new ChangeCityStatusCommand(
Id: id,
NewStatus: request.NewStatus);

return await sender.Send(command, cancellationToken);
}
}
Loading