Add description
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing

This commit is contained in:
Kasper Juul Hermansen 2021-11-18 16:53:21 +01:00
parent afb3bfd681
commit c281d5a6a7
Signed by: kjuulh
GPG Key ID: DCD9397082D97069
39 changed files with 639 additions and 416 deletions

View File

@ -1,18 +1,17 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Todo.Core.Interfaces.Persistence;
namespace Todo.Api.Controllers
namespace Todo.Api.Controllers;
[ApiController]
[Route("api/auth")]
[Authorize]
[AllowAnonymous]
public class AuthController : ControllerBase
{
[ApiController]
[Route("api/auth")]
[Authorize]
[AllowAnonymous]
public class AuthController : ControllerBase
{
private readonly IUserRepository _userRepository;
public AuthController(IUserRepository userRepository)
@ -21,9 +20,12 @@ namespace Todo.Api.Controllers
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterUserRequest request)
public async Task<IActionResult> Register(
[FromBody] RegisterUserRequest request)
{
var user = await _userRepository.Register(request.Email, request.Password);
var user = await _userRepository.Register(
request.Email,
request.Password);
return Ok(user);
}
@ -36,7 +38,9 @@ namespace Todo.Api.Controllers
RedirectUri = Url.Action(nameof(Callback)),
Items =
{
{"returnUrl", returnUrl}
{
"returnUrl", returnUrl
}
}
};
return Challenge(props);
@ -49,9 +53,7 @@ namespace Todo.Api.Controllers
var result =
await HttpContext.AuthenticateAsync("oidc");
if (result?.Succeeded != true)
{
throw new Exception("External authentication error");
}
var returnUrl = result.Properties?.Items["returnUrl"] ?? "~/";
return Redirect(returnUrl);
@ -59,8 +61,10 @@ namespace Todo.Api.Controllers
public record RegisterUserRequest
{
[Required] public string Email { get; init; }
[Required] public string Password { get; init; }
}
[Required]
public string Email { get; init; }
[Required]
public string Password { get; init; }
}
}

View File

@ -19,25 +19,37 @@ public class TodosController : ControllerBase
[HttpPost]
[Authorize]
public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request)
public async Task<ActionResult<Core.Entities.Todo>> CreateTodo(
[FromBody] CreateTodoRequest request)
{
var userId = User.FindFirstValue("sub") ??
throw new InvalidOperationException("Could not get user, something has gone terribly wrong");
return Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty, userId));
return Ok(
await _todoRepository.CreateTodoAsync(
request.Title,
string.Empty,
"",
userId));
}
[HttpGet]
[Authorize]
public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetTodos() =>
Ok(await _todoRepository.GetTodosAsync());
public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetTodos()
{
return Ok(await _todoRepository.GetTodosAsync());
}
[HttpGet("not-done")]
public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetNotDoneTodos() =>
Ok(await _todoRepository.GetNotDoneTodos());
public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>>
GetNotDoneTodos()
{
return Ok(await _todoRepository.GetNotDoneTodos());
}
public record CreateTodoRequest
{
[Required] public string Title { get; init; }
[Required]
public string Title { get; init; }
}
}

View File

@ -10,9 +10,12 @@ public record TodoResponse
[JsonPropertyName("title")]
public string Title { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("status")]
public bool Status { get; init; }
[JsonPropertyName("project")]
public string Project { get; set; }
public string? Project { get; init; }
}

View File

@ -14,5 +14,8 @@ public record UpdateTodoRequest
public bool Status { get; init; }
[JsonPropertyName("project")]
public string Project { get; set; }
public string? Project { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}

View File

@ -1,5 +1,3 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Text.Json;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@ -9,17 +7,28 @@ using Todo.Core.Application.Commands.Todo;
using Todo.Core.Application.Services.UserConnectionStore;
using Todo.Core.Interfaces.Persistence;
using Todo.Core.Interfaces.User;
using Todo.Infrastructure;
namespace Todo.Api.Hubs;
[Authorize]
public class TodoHub : Hub
{
private readonly ITodoRepository _todoRepository;
private readonly IUserConnectionStore _userConnectionStore;
private readonly ICurrentUserService _currentUserService;
private readonly IMediator _mediator;
private readonly ITodoRepository _todoRepository;
private readonly IUserConnectionStore _userConnectionStore;
public TodoHub(
ITodoRepository todoRepository,
IUserConnectionStore userConnectionStore,
ICurrentUserService currentUserService,
IMediator mediator)
{
_todoRepository = todoRepository;
_userConnectionStore = userConnectionStore;
_currentUserService = currentUserService;
_mediator = mediator;
}
public override Task OnConnectedAsync()
{
@ -38,19 +47,10 @@ public class TodoHub : Hub
return base.OnDisconnectedAsync(exception);
}
public TodoHub(
ITodoRepository todoRepository,
IUserConnectionStore userConnectionStore,
ICurrentUserService currentUserService,
IMediator mediator)
{
_todoRepository = todoRepository;
_userConnectionStore = userConnectionStore;
_currentUserService = currentUserService;
_mediator = mediator;
}
public async Task CreateTodo(string todoTitle, string? projectName)
public async Task CreateTodo(
string todoTitle,
string? projectName,
string? description)
{
if (todoTitle is null)
throw new ArgumentException("title cannot be null");
@ -58,7 +58,11 @@ public class TodoHub : Hub
//var userId = GetUserId();
//var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName, userId);
await _mediator.Send(new CreateTodoCommand(todoTitle, projectName));
await _mediator.Send(
new CreateTodoCommand(
todoTitle,
projectName,
description));
await GetInboxTodos();
}
@ -67,7 +71,10 @@ public class TodoHub : Hub
public async Task UpdateTodo(string todoId, bool todoStatus)
{
var userId = GetUserId();
await _todoRepository.UpdateTodoStatus(todoId, todoStatus, userId);
await _todoRepository.UpdateTodoStatus(
todoId,
todoStatus,
userId);
await GetInboxTodos();
}
@ -75,78 +82,115 @@ public class TodoHub : Hub
public async Task GetTodos()
{
var todos = await _todoRepository.GetTodosAsync();
var serializedTodos = JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project })
var serializedTodos = JsonSerializer.Serialize(
todos
.Select(
t => new TodoResponse
{
Id = t.Id,
Title = t.Title,
Status = t.Status,
Project = t.Project,
Description = t.Description
})
.ToList());
await RunOnUserConnections(async (connections) =>
await Clients.Clients(connections).SendAsync("todos", serializedTodos));
await RunOnUserConnections(
async connections =>
await Clients.Clients(connections)
.SendAsync("todos", serializedTodos));
}
public async Task GetInboxTodos()
{
var todos = await _todoRepository.GetNotDoneTodos();
var serializedTodos = JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project })
var serializedTodos = JsonSerializer.Serialize(
todos
.Select(
t => new TodoResponse
{
Id = t.Id,
Title = t.Title,
Status = t.Status,
Project = t.Project,
Description = t.Description
})
.ToList());
await RunOnUserConnections(async (connections) =>
await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos));
await RunOnUserConnections(
async connections =>
await Clients.Clients(connections)
.SendAsync("getInboxTodos", serializedTodos));
}
public async Task GetTodo(string todoId)
{
var todo = await _todoRepository.GetTodoByIdAsync(todoId);
var serializedTodo = JsonSerializer.Serialize(new TodoResponse()
var serializedTodo = JsonSerializer.Serialize(
new TodoResponse
{
Id = todo.Id,
Project = todo.Project,
Status = todo.Status,
Title = todo.Title,
Description = todo.Description
});
await RunOnUserConnections(async (connections) =>
await Clients.Clients(connections).SendAsync("getTodo", serializedTodo));
await RunOnUserConnections(
async connections =>
await Clients.Clients(connections)
.SendAsync("getTodo", serializedTodo));
}
public async Task ReplaceTodo(string updateTodoRequest)
{
var updateTodo = JsonSerializer.Deserialize<UpdateTodoRequest>(updateTodoRequest);
var updateTodo =
JsonSerializer.Deserialize<UpdateTodoRequest>(updateTodoRequest);
if (updateTodo is null)
throw new InvalidOperationException("Could not parse invalid updateTodo");
var userId = GetUserId();
var updatedTodo = await _todoRepository.UpdateTodoAsync(new Core.Entities.Todo()
var updatedTodo = await _todoRepository.UpdateTodoAsync(
new Core.Entities.Todo
{
Id = updateTodo.Id,
Project = updateTodo.Project,
Status = updateTodo.Status,
Title = updateTodo.Title,
AuthorId = userId
AuthorId = userId,
Description = updateTodo.Description
});
var serializedTodo = JsonSerializer.Serialize(new TodoResponse()
var serializedTodo = JsonSerializer.Serialize(
new TodoResponse
{
Id = updatedTodo.Id,
Project = updatedTodo.Project,
Status = updatedTodo.Status,
Title = updatedTodo.Title,
Description = updatedTodo.Description
});
await RunOnUserConnections(async (connections) =>
await Clients.Clients(connections).SendAsync("getTodo", serializedTodo));
await RunOnUserConnections(
async connections =>
await Clients.Clients(connections)
.SendAsync("getTodo", serializedTodo));
}
private async Task RunOnUserConnections(Func<IEnumerable<string>, Task> action)
private async Task RunOnUserConnections(
Func<IEnumerable<string>, Task> action)
{
var userId = GetUserId();
var connections = await _userConnectionStore.GetConnectionsAsync(userId);
var connections =
await _userConnectionStore.GetConnectionsAsync(userId);
await action(connections);
}
private string GetUserId() =>
_currentUserService.GetUserId() ??
private string GetUserId()
{
return _currentUserService.GetUserId() ??
throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong");
}
}

View File

@ -3,19 +3,20 @@ global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Todo.Api
namespace Todo.Api;
public class Program
{
public class Program
public static void Main(string[] args)
{
public static void Main(string[] args) =>
CreateHostBuilder(args).Build().Run();
}
private static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
private static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
}
}

View File

@ -10,10 +10,10 @@ namespace Todo.Api.Publishers;
public class TodoPublisher : ITodoPublisher
{
private readonly IHubContext<TodoHub> _hubContext;
private readonly ICurrentUserService _currentUserService;
private readonly IUserConnectionStore _userConnectionStore;
private readonly IHubContext<TodoHub> _hubContext;
private readonly ILogger<TodoPublisher> _logger;
private readonly IUserConnectionStore _userConnectionStore;
public TodoPublisher(
IHubContext<TodoHub> hubContext,
@ -27,15 +27,22 @@ public class TodoPublisher : ITodoPublisher
_logger = logger;
}
public async Task Publish(string todoId, CancellationToken cancellationToken)
public async Task Publish(
string todoId,
CancellationToken cancellationToken)
{
var userId = _currentUserService.GetUserId() ?? throw new InvalidOperationException("Cannot proceed without user");
var connections = await _userConnectionStore.GetConnectionsAsync(userId);
var userId = _currentUserService.GetUserId() ??
throw new InvalidOperationException("Cannot proceed without user");
var connections =
await _userConnectionStore.GetConnectionsAsync(userId);
await _hubContext
.Clients
.Clients(connections)
.SendAsync("todoCreated", todoId , cancellationToken);
.SendAsync(
"todoCreated",
todoId,
cancellationToken);
_logger.LogInformation("todo created {TodoId}", todoId);
}

View File

@ -8,10 +8,14 @@ public class HttpContextCurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextCurrentUserService(IHttpContextAccessor httpContextAccessor)
public HttpContextCurrentUserService(
IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string? GetUserId() => _httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
public string? GetUserId()
{
return _httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
}
}

View File

@ -10,17 +10,17 @@ using Microsoft.OpenApi.Models;
using Todo.Api.Hubs;
using Todo.Api.Publishers;
using Todo.Api.Services;
using Todo.Core;
using Todo.Core.Interfaces.Publisher;
using Todo.Core.Interfaces.User;
using Todo.Infrastructure;
using Todo.Persistence;
using Todo.Persistence.Mongo;
using Todo.Core;
using Todo.Core.Interfaces.Publisher;
namespace Todo.Api
namespace Todo.Api;
public class Startup
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
@ -33,22 +33,34 @@ namespace Todo.Api
{
services.AddControllers();
services.AddHttpContextAccessor();
services.AddCors(options =>
services.AddCors(
options =>
{
options.AddDefaultPolicy(builder =>
builder.WithOrigins("http://localhost:3000", "https://todo.front.kjuulh.io")
options.AddDefaultPolicy(
builder =>
builder.WithOrigins(
"http://localhost:3000",
"https://todo.front.kjuulh.io")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod());
});
services.AddCore();
services.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();
services
.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();
services.AddScoped<ITodoPublisher, TodoPublisher>();
services.AddSwaggerGen(c =>
services.AddSwaggerGen(
c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Todo.Api", Version = "v1" });
c.SwaggerDoc(
"v1",
new OpenApiInfo
{
Title = "Todo.Api",
Version = "v1"
});
});
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
@ -57,7 +69,9 @@ namespace Todo.Api
services.AddPersistence(Configuration, out var mongoDbOptions);
services
.AddHealthChecks()
.AddMongoDb(MongoDbConnectionHandler.FormatConnectionString(mongoDbOptions));
.AddMongoDb(
MongoDbConnectionHandler
.FormatConnectionString(mongoDbOptions));
services.AddSignalR();
}
@ -70,7 +84,10 @@ namespace Todo.Api
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Todo.Api v1"));
app.UseSwaggerUI(
c => c.SwaggerEndpoint(
"/swagger/v1/swagger.json",
"Todo.Api v1"));
}
app.UseRouting();
@ -79,18 +96,22 @@ namespace Todo.Api
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
app.UseEndpoints(
endpoints =>
{
endpoints.MapControllers();
endpoints.MapHub<TodoHub>("/hubs/todo");
endpoints.MapHealthChecks("/health/live", new HealthCheckOptions()
endpoints.MapHealthChecks(
"/health/live",
new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
var response = new HealthCheckResponse()
var response = new HealthCheckResponse
{
Status = report.Status.ToString(),
HealthChecks = report.Entries.Select(x => new IndividualHealthCheckResponse
HealthChecks = report.Entries.Select(
x => new IndividualHealthCheckResponse
{
Component = x.Key,
Status = x.Value.Status.ToString(),
@ -107,7 +128,9 @@ namespace Todo.Api
private class HealthCheckResponse
{
public string Status { get; set; }
public IEnumerable<IndividualHealthCheckResponse> HealthChecks { get; set; }
public TimeSpan HealthCheckDuration { get; set; }
}
@ -117,5 +140,4 @@ namespace Todo.Api
public string Component { get; set; }
public string Description { get; set; }
}
}
}

View File

@ -7,13 +7,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Todo.Infrastructure\Todo.Infrastructure.csproj" />
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj" />
<ProjectReference Include="..\Todo.Persistence\Todo.Persistence.csproj" />
<ProjectReference Include="..\Todo.Infrastructure\Todo.Infrastructure.csproj"/>
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj"/>
<ProjectReference Include="..\Todo.Persistence\Todo.Persistence.csproj"/>
</ItemGroup>
</Project>

View File

@ -6,30 +6,44 @@ using Todo.Core.Interfaces.User;
namespace Todo.Core.Application.Commands.Todo;
public record CreateTodoCommand(string TodoTitle, string? TodoProject) : IRequest<string>
public record CreateTodoCommand(
string TodoTitle,
string? TodoProject,
string? TodoDescription) : IRequest<string>
{
internal class Handler : IRequestHandler<CreateTodoCommand, string>
{
private readonly ICurrentUserService _currentUserService;
private readonly ITodoRepository _todoRepository;
private readonly IMediator _mediator;
private readonly ITodoRepository _todoRepository;
public Handler(ICurrentUserService currentUserService, ITodoRepository todoRepository, IMediator mediator)
public Handler(
ICurrentUserService currentUserService,
ITodoRepository todoRepository,
IMediator mediator)
{
_currentUserService = currentUserService;
_todoRepository = todoRepository;
_mediator = mediator;
}
public async Task<string> Handle(CreateTodoCommand request, CancellationToken cancellationToken)
public async Task<string> Handle(
CreateTodoCommand request,
CancellationToken cancellationToken)
{
var userId = _currentUserService.GetUserId();
if (userId is null)
throw new InvalidOperationException("User was not found");
var todo = await _todoRepository.CreateTodoAsync(request.TodoTitle, request.TodoProject, userId);
var todo = await _todoRepository.CreateTodoAsync(
request.TodoTitle,
request.TodoProject,
request.TodoDescription,
userId);
await _mediator.Publish(new TodoCreated(todo.Id), cancellationToken);
await _mediator.Publish(
new TodoCreated(todo.Id),
cancellationToken);
return todo.Id;
}

View File

@ -2,14 +2,12 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Todo.Core.Application.Services.UserConnectionStore;
using Todo.Core.Interfaces.Publisher;
using Todo.Core.Interfaces.User;
namespace Todo.Core.Application.Notifications.Todo;
public record TodoCreated([property: JsonPropertyName("todoId")] string TodoId) : INotification
public record TodoCreated(
[property: JsonPropertyName("todoId")] string TodoId) : INotification
{
internal class Handler : INotificationHandler<TodoCreated>
{
@ -20,9 +18,13 @@ public record TodoCreated([property: JsonPropertyName("todoId")] string TodoId)
_todoPublisher = todoPublisher;
}
public async Task Handle(TodoCreated notification, CancellationToken cancellationToken)
public async Task Handle(
TodoCreated notification,
CancellationToken cancellationToken)
{
await _todoPublisher.Publish(JsonSerializer.Serialize(notification), cancellationToken);
await _todoPublisher.Publish(
JsonSerializer.Serialize(notification),
cancellationToken);
}
}
}

View File

@ -4,5 +4,5 @@ namespace Todo.Core.Interfaces.Publisher;
public interface ITodoPublisher
{
Task Publish(string todoId, CancellationToken cancellationToken = new ());
Task Publish(string todoId, CancellationToken cancellationToken = new());
}

View File

@ -10,5 +10,8 @@ namespace Todo.Core;
public static class DependencyInjection
{
public static IServiceCollection AddCore(this IServiceCollection services) => services.AddMediatR(Assembly.GetExecutingAssembly());
public static IServiceCollection AddCore(this IServiceCollection services)
{
return services.AddMediatR(Assembly.GetExecutingAssembly());
}
}

View File

@ -5,6 +5,7 @@ public record Todo
public string Id { get; init; }
public string Title { get; init; }
public bool Status { get; init; }
public string Project { get; init; }
public string? Project { get; init; }
public string AuthorId { get; init; }
public string? Description { get; init; }
}

View File

@ -1,8 +1,7 @@
namespace Todo.Core.Entities
namespace Todo.Core.Entities;
public class User
{
public class User
{
public string Id { get; set; }
public string Email { get; set; }
}
}

View File

@ -1,11 +1,20 @@
namespace Todo.Core.Interfaces.Persistence;
public interface ITodoRepository
{
Task<Entities.Todo> CreateTodoAsync(string title, string projectName, string userId);
Task<Entities.Todo> CreateTodoAsync(
string title,
string? projectName,
string? description,
string userId);
Task<IEnumerable<Entities.Todo>> GetTodosAsync();
Task UpdateTodoStatus(string todoId, bool todoStatus, string userId);
Task UpdateTodoStatus(
string todoId,
bool todoStatus,
string userId);
Task<IEnumerable<Entities.Todo>> GetNotDoneTodos();
Task<Entities.Todo> GetTodoByIdAsync(string todoId);
Task<Entities.Todo> UpdateTodoAsync(Entities.Todo todo);

View File

@ -1,5 +1,3 @@
using Todo.Core.Entities;
namespace Todo.Core.Interfaces.Persistence;
public interface IUserRepository

View File

@ -6,16 +6,16 @@
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Application\Services" />
<Folder Include="Application\Services"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Mediatr" Version="9.0.0" />
<PackageReference Include="Mediatr.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Mediatr" Version="9.0.0"/>
<PackageReference Include="Mediatr.Extensions.Microsoft.DependencyInjection" Version="9.0.0"/>
</ItemGroup>
</Project>

View File

@ -10,7 +10,9 @@ namespace Todo.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
var giteaAuthOptions = new GiteaAuthOptions();
var giteaOptions = configuration.GetRequiredSection("GITEA");
@ -21,15 +23,19 @@ public static class DependencyInjection
.Bind(giteaOptions)
.ValidateDataAnnotations();
services.AddSingleton<IUserConnectionStore, InMemoryUserConnectionStore>();
services
.AddSingleton<IUserConnectionStore, InMemoryUserConnectionStore>();
return services.AddAuthentication(options =>
return services.AddAuthentication(
options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
.AddOpenIdConnect(
"oidc",
options =>
{
options.Authority = giteaAuthOptions.Url;
options.ClientId = giteaAuthOptions.ClientId;
@ -37,19 +43,29 @@ public static class DependencyInjection
options.ResponseType = "code";
options.SaveTokens = true;
}).Services;
})
.Services;
}
public static IApplicationBuilder UseInfrastructure(this IApplicationBuilder app) => app.UseCookiePolicy(
public static IApplicationBuilder UseInfrastructure(
this IApplicationBuilder app)
{
return app.UseCookiePolicy(
new CookiePolicyOptions
{
Secure = CookieSecurePolicy.Always
});
}
}
public class GiteaAuthOptions
{
[Required] public string Url { get; set; }
[Required] public string ClientId { get; init; }
[Required] public string ClientSecret { get; init; }
[Required]
public string Url { get; set; }
[Required]
public string ClientId { get; init; }
[Required]
public string ClientSecret { get; init; }
}

View File

@ -7,15 +7,15 @@
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj" />
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj"/>
</ItemGroup>
</Project>

View File

@ -3,9 +3,10 @@ using Todo.Core.Application.Services.UserConnectionStore;
namespace Todo.Infrastructure.UserConnectionStore;
class InMemoryUserConnectionStore : IUserConnectionStore
internal class InMemoryUserConnectionStore : IUserConnectionStore
{
private static readonly ConcurrentDictionary<string, List<string>> ConnectedUsers = new();
private static readonly ConcurrentDictionary<string, List<string>>
ConnectedUsers = new();
public Task AddAsync(string userId, string connectionId)
{
@ -27,7 +28,10 @@ class InMemoryUserConnectionStore : IUserConnectionStore
public Task<IEnumerable<string>> GetConnectionsAsync(string userId)
{
ConnectedUsers.TryGetValue(userId, out var connections);
return Task.FromResult(connections is null ? new List<string>().AsEnumerable() : connections.AsEnumerable());
return Task.FromResult(
connections is null
? new List<string>().AsEnumerable()
: connections.AsEnumerable());
}
public Task RemoveAsync(string userId, string connectionId)
@ -39,11 +43,9 @@ class InMemoryUserConnectionStore : IUserConnectionStore
// If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers).
if (existingUserConnectionIds?.Count == 0)
{
// if there are no connections for the user,
// just delete the userName key from the ConnectedUsers concurent dictionary
ConnectedUsers.TryRemove(userId, out _);
}
return Task.CompletedTask;
}

View File

@ -4,16 +4,17 @@ global using System.Linq;
global using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Todo.Core.Interfaces.Persistence;
using Todo.Persistence.Mongo;
using Todo.Persistence.Mongo.Repositories;
namespace Todo.Persistence
namespace Todo.Persistence;
public static class DependencyInjection
{
public static class DependencyInjection
{
public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration,
public static IServiceCollection AddPersistence(
this IServiceCollection services,
IConfiguration configuration,
out MongoDbOptions mongoDbOptions)
{
var exportableMongoDbOptions = new MongoDbOptions();
@ -31,5 +32,4 @@ namespace Todo.Persistence
.AddScoped<IUserRepository, UserRepository>()
.AddScoped<ITodoRepository, TodoRepository>();
}
}
}

View File

@ -3,19 +3,23 @@ using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using Todo.Persistence.Mongo.Repositories.Dtos;
namespace Todo.Persistence.Mongo
namespace Todo.Persistence.Mongo;
public static class Migrations
{
public static class Migrations
public static IApplicationBuilder MigrateMongoDb(
this IApplicationBuilder app)
{
public static IApplicationBuilder MigrateMongoDb(this IApplicationBuilder app)
Task.Run(
async () =>
{
Task.Run(async () =>
{
var connectionHandler = app.ApplicationServices.GetRequiredService<MongoDbConnectionHandler>();
var connectionHandler = app.ApplicationServices
.GetRequiredService<MongoDbConnectionHandler>();
var database = connectionHandler.CreateDatabaseConnection();
await CreateIndexes(database);
}).Wait(10000);
})
.Wait(10000);
return app;
}
@ -24,12 +28,17 @@ namespace Todo.Persistence.Mongo
{
Console.WriteLine("Running: CreateIndexes");
var collection = mongoDatabase.GetCollection<MongoUser>("users");
var indexKeysDefinition = Builders<MongoUser>.IndexKeys.Ascending(user => user.Email);
var indexKeysDefinition =
Builders<MongoUser>.IndexKeys.Ascending(user => user.Email);
var indexModel =
new CreateIndexModel<MongoUser>(indexKeysDefinition, new CreateIndexOptions() { Unique = true });
new CreateIndexModel<MongoUser>(
indexKeysDefinition,
new CreateIndexOptions
{
Unique = true
});
await collection.Indexes.CreateOneAsync(indexModel);
Console.WriteLine("Finished: CreateIndexes");
}
}
}

View File

@ -7,7 +7,8 @@ public class MongoDbConnectionHandler
{
private readonly IOptionsMonitor<MongoDbOptions> _optionsMonitor;
public MongoDbConnectionHandler(IOptionsMonitor<MongoDbOptions> optionsMonitor)
public MongoDbConnectionHandler(
IOptionsMonitor<MongoDbOptions> optionsMonitor)
{
_optionsMonitor = optionsMonitor;
}
@ -19,7 +20,8 @@ public class MongoDbConnectionHandler
return CreateConnectionFromOptions(options);
}
private static IMongoDatabase CreateConnectionFromOptions(MongoDbOptions options)
private static IMongoDatabase CreateConnectionFromOptions(
MongoDbOptions options)
{
var conn = new MongoClient(FormatConnectionString(options));
var database = conn.GetDatabase(options.Database);
@ -27,6 +29,9 @@ public class MongoDbConnectionHandler
return database;
}
public static string FormatConnectionString(MongoDbOptions options) =>
public static string FormatConnectionString(MongoDbOptions options)
{
return
$"mongodb://{options.Username}:{options.Password}@{options.Host}:{options.Port}";
}
}

View File

@ -6,9 +6,18 @@ public class MongoDbOptions
{
public const string MongoDb = "MongoDb";
[Required] public string Username { get; set; }
[Required] public string Password { get; set; }
[Required] public string Host { get; set; }
[Required] public string Port { get; set; }
[Required] public string Database { get; set; }
[Required]
public string Username { get; set; }
[Required]
public string Password { get; set; }
[Required]
public string Host { get; set; }
[Required]
public string Port { get; set; }
[Required]
public string Database { get; set; }
}

View File

@ -9,8 +9,14 @@ public record MongoTodo
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; init; }
[BsonRequired] public string Title { get; init; }
[BsonRequired] public bool Status { get; set; }
public string ProjectName { get; set; } = String.Empty;
[BsonRequired]
public string Title { get; init; }
public string? Description { get; init; }
[BsonRequired]
public bool Status { get; set; }
public string? ProjectName { get; set; } = string.Empty;
public string AuthorId { get; set; }
}

View File

@ -1,15 +1,17 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Todo.Persistence.Mongo.Repositories.Dtos
namespace Todo.Persistence.Mongo.Repositories.Dtos;
public record MongoUser
{
public record MongoUser
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; init; }
[BsonRequired] public string Email { get; init; }
[BsonRequired] public string Password { get; set; }
}
[BsonRequired]
public string Email { get; init; }
[BsonRequired]
public string Password { get; set; }
}

View File

@ -15,12 +15,28 @@ public class TodoRepository : ITodoRepository
_todosCollection = database.GetCollection<MongoTodo>("todos");
}
public async Task<Core.Entities.Todo> CreateTodoAsync(string title, string projectName, string userId)
public async Task<Core.Entities.Todo> CreateTodoAsync(
string title,
string projectName,
string? description,
string userId)
{
var todo = new MongoTodo() { Title = title, ProjectName = projectName, AuthorId = userId };
var todo = new MongoTodo
{
Title = title,
ProjectName = projectName,
AuthorId = userId,
Description = description
};
await _todosCollection.InsertOneAsync(todo);
return new Core.Entities.Todo()
{ Id = todo.Id, Title = todo.Title, Status = false, Project = todo.ProjectName };
return new Core.Entities.Todo
{
Id = todo.Id,
Title = todo.Title,
Status = false,
Project = todo.ProjectName,
Description = todo.Description
};
}
public async Task<IEnumerable<Core.Entities.Todo>> GetTodosAsync()
@ -28,14 +44,26 @@ public class TodoRepository : ITodoRepository
var todos = await _todosCollection.FindAsync(_ => true);
return todos
.ToEnumerable()
.Select(t =>
new Core.Entities.Todo() { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.ProjectName });
.Select(
t =>
new Core.Entities.Todo
{
Id = t.Id,
Title = t.Title,
Status = t.Status,
Project = t.ProjectName,
Description = t.Description
});
}
public async Task UpdateTodoStatus(string todoId, bool todoStatus, string userId)
public async Task UpdateTodoStatus(
string todoId,
bool todoStatus,
string userId)
{
await _todosCollection
.UpdateOneAsync(t => t.Id == todoId && t.AuthorId == userId,
.UpdateOneAsync(
t => t.Id == todoId && t.AuthorId == userId,
Builders<MongoTodo>.Update.Set(t => t.Status, todoStatus));
}
@ -50,33 +78,39 @@ public class TodoRepository : ITodoRepository
var todoCursor = await _todosCollection.FindAsync(f => f.Id == todoId);
var todo = await todoCursor.FirstOrDefaultAsync();
return new Core.Entities.Todo()
return new Core.Entities.Todo
{
Id = todo.Id,
Project = todo.ProjectName,
Status = todo.Status,
Title = todo.Title
Title = todo.Title,
Description = todo.Description
};
}
public async Task<Core.Entities.Todo> UpdateTodoAsync(Core.Entities.Todo todo)
public async Task<Core.Entities.Todo> UpdateTodoAsync(
Core.Entities.Todo todo)
{
var updatedTodo = await _todosCollection.FindOneAndReplaceAsync<MongoTodo>(f => f.Id == todo.Id, new MongoTodo()
var updatedTodo = await _todosCollection.FindOneAndReplaceAsync(
f => f.Id == todo.Id,
new MongoTodo
{
Id = todo.Id,
Status = todo.Status,
Title = todo.Title,
ProjectName = todo.Project,
AuthorId = todo.AuthorId
AuthorId = todo.AuthorId,
Description = todo.Description
});
return new Core.Entities.Todo()
return new Core.Entities.Todo
{
Id = updatedTodo.Id,
Project = updatedTodo.ProjectName,
Status = updatedTodo.Status,
Title = updatedTodo.Title,
AuthorId = updatedTodo.AuthorId
AuthorId = updatedTodo.AuthorId,
Description = updatedTodo.Description
};
}
}

View File

@ -3,10 +3,10 @@ using Todo.Core.Entities;
using Todo.Core.Interfaces.Persistence;
using Todo.Persistence.Mongo.Repositories.Dtos;
namespace Todo.Persistence.Mongo.Repositories
namespace Todo.Persistence.Mongo.Repositories;
public class UserRepository : IUserRepository
{
public class UserRepository : IUserRepository
{
private readonly IMongoCollection<MongoUser> _usersCollection;
public UserRepository(MongoDbConnectionHandler mongoDbConnectionHandler)
@ -17,9 +17,16 @@ namespace Todo.Persistence.Mongo.Repositories
public async Task<User> Register(string email, string password)
{
var dtoUser = new MongoUser() { Email = email, Password = password };
var dtoUser = new MongoUser
{
Email = email,
Password = password
};
await _usersCollection.InsertOneAsync(dtoUser);
return new User() { Email = dtoUser.Email, Id = dtoUser.Id };
}
return new User
{
Email = dtoUser.Email,
Id = dtoUser.Id
};
}
}

View File

@ -2,20 +2,21 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.13.2" />
<PackageReference Include="MongoDB.Driver.Core" Version="2.13.2" />
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="5.0.1" />
<PackageReference Include="MongoDB.Driver" Version="2.13.2"/>
<PackageReference Include="MongoDB.Driver.Core" Version="2.13.2"/>
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="5.0.1"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj" />
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj"/>
</ItemGroup>
</Project>

View File

@ -4,7 +4,7 @@ import { PrimaryButton } from "@src/components/common/buttons/primaryButton";
import { TodoShortForm } from "@src/components/todos/collapsed/todoShortForm";
export const AddTodoForm: FC<{
onAdd: (todoName: string, project: string) => void;
onAdd: (todoName: string, project: string, description: string) => void;
onClose: () => void;
project: string;
}> = ({ onAdd, onClose, ...props }) => {
@ -16,7 +16,7 @@ export const AddTodoForm: FC<{
<form
onSubmit={(e) => {
e.preventDefault();
onAdd(todoName, project);
onAdd(todoName, project, todoDescription);
setTodoName("");
setTodoDescription("");
}}

View File

@ -22,8 +22,8 @@ export function AddTodo(props: { project: string }) {
return (
<AddTodoForm
project={props.project}
onAdd={(todoName, project) => {
createTodo(todoName, project);
onAdd={(todoName, project, description) => {
createTodo(todoName, project, description);
}}
onClose={() => setCollapsed(CollapsedState.collapsed)}
/>

View File

@ -29,7 +29,12 @@ export const TodoItem: FC<TodoItemProps> = (props) => {
{props.todo.title}
</a>
</Link>
<div className="flex flex-row justify-between items-end">
<div>
{props.todo.description && (
<div className="h-3 w-3 bg-gray-200 dark:bg-gray-900"/>
)}
</div>
{props.displayProject && props.todo.project && (
<div className="text-gray-500 text-xs text-right whitespace-nowrap place-self-end">
{props.todo.project}

View File

@ -9,6 +9,7 @@ export const StatusState: { done: Done; notDone: NotDone } = {
export interface Todo {
id: string;
title: string;
description?: string;
status: StatusState;
project?: string;
}

View File

@ -12,7 +12,7 @@ interface SocketContextProps {
inboxTodos: Todo[];
getTodos: () => void;
getInboxTodos: () => void;
createTodo: (todoName: string, project: string) => void;
createTodo: (todoName: string, project: string, description: string) => void;
updateTodo: (todoId: string, todoStatus: StatusState) => void;
getTodoById(todoId: string): void;
replaceTodo(todo: Todo): void;
@ -24,7 +24,7 @@ export const SocketContext = createContext<SocketContextProps>({
inboxTodos: [],
getTodos: () => {},
getInboxTodos: () => {},
createTodo: (todoName, project) => {},
createTodo: (todoName, project, description) => {},
updateTodo: (todoId, todoStatus) => {},
getTodoById(todoId: string) {},
replaceTodo(todo: Todo) {},
@ -89,8 +89,8 @@ export const SocketProvider: FC = (props) => {
getInboxTodos: () => {
conn.invoke("GetInboxTodos").catch(console.error);
},
createTodo: (todoName, project) => {
conn.invoke("CreateTodo", todoName, project).catch(console.error);
createTodo: (todoName, project, description) => {
conn.invoke("CreateTodo", todoName, project, description).catch(console.error);
},
updateTodo: (todoId, todoStatus) => {
conn.invoke("UpdateTodo", todoId, todoStatus).catch(console.error);

View File

@ -19,8 +19,8 @@ export const useCreateTodo = () => {
const socketContext = useContext(SocketContext);
return {
createTodo: (todoName: string, project: string) => {
socketContext.createTodo(todoName, project);
createTodo: (todoName: string, project: string, description: string) => {
socketContext.createTodo(todoName, project, description);
},
};
};