From cadc321d9611ed3a4ff5dcfdca253abebc1c9d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ty=C5=84ski?= Date: Sun, 4 Oct 2026 02:22:00 +0200 Subject: [PATCH] feat(backend): add problem/need submission module with workflow and events Add submission CRUD with pagination and role scoping, challenge/location binding, automatic keyword extraction, a validated status workflow, and domain events for matchmaking and notifications. Create a problem record together with each submission. --- .../Abstractions/ISubmissionEventPublisher.cs | 13 + .../Abstractions/ISubmissionService.cs | 28 +++ backend/Application/Dtos/PagedResponse.cs | 7 + backend/Application/Dtos/SubmissionDtos.cs | 26 ++ .../Mappings/SubmissionStatusWire.cs | 36 +++ backend/Application/Text/KeywordExtractor.cs | 70 ++++++ backend/Controllers/SubmissionsController.cs | 137 +++++++++++ backend/Domain/Rules/SubmissionWorkflow.cs | 26 ++ backend/Infrastructure/DependencyInjection.cs | 3 + .../Events/LoggingSubmissionEventPublisher.cs | 30 +++ .../Persistence/SubmissionService.cs | 227 ++++++++++++++++++ .../Infrastructure/Persistence/SurrealRows.cs | 30 +++ 12 files changed, 633 insertions(+) create mode 100644 backend/Application/Abstractions/ISubmissionEventPublisher.cs create mode 100644 backend/Application/Abstractions/ISubmissionService.cs create mode 100644 backend/Application/Dtos/PagedResponse.cs create mode 100644 backend/Application/Dtos/SubmissionDtos.cs create mode 100644 backend/Application/Mappings/SubmissionStatusWire.cs create mode 100644 backend/Application/Text/KeywordExtractor.cs create mode 100644 backend/Controllers/SubmissionsController.cs create mode 100644 backend/Domain/Rules/SubmissionWorkflow.cs create mode 100644 backend/Infrastructure/Events/LoggingSubmissionEventPublisher.cs create mode 100644 backend/Infrastructure/Persistence/SubmissionService.cs diff --git a/backend/Application/Abstractions/ISubmissionEventPublisher.cs b/backend/Application/Abstractions/ISubmissionEventPublisher.cs new file mode 100644 index 0000000..f131047 --- /dev/null +++ b/backend/Application/Abstractions/ISubmissionEventPublisher.cs @@ -0,0 +1,13 @@ +namespace TarninaHub.Application.Abstractions; + +public sealed record SubmissionEvent( + string Type, + string SubmissionId, + string? ActorId, + string Status, + DateTimeOffset At); + +public interface ISubmissionEventPublisher +{ + Task PublishAsync(SubmissionEvent submissionEvent, CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/ISubmissionService.cs b/backend/Application/Abstractions/ISubmissionService.cs new file mode 100644 index 0000000..bbe5358 --- /dev/null +++ b/backend/Application/Abstractions/ISubmissionService.cs @@ -0,0 +1,28 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface ISubmissionService +{ + Task CreateAsync( + string authorId, + CreateSubmissionRequest request, + CancellationToken cancellationToken); + + Task> ListAsync( + string viewerId, + bool isStaff, + string? status, + int page, + int pageSize, + CancellationToken cancellationToken); + + Task GetAsync( + string submissionId, + CancellationToken cancellationToken); + + Task UpdateStatusAsync( + string submissionId, + string newStatus, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Dtos/PagedResponse.cs b/backend/Application/Dtos/PagedResponse.cs new file mode 100644 index 0000000..ea984cd --- /dev/null +++ b/backend/Application/Dtos/PagedResponse.cs @@ -0,0 +1,7 @@ +namespace TarninaHub.Application.Dtos; + +public sealed record PagedResponse( + IReadOnlyList Items, + int Page, + int PageSize, + int Total); diff --git a/backend/Application/Dtos/SubmissionDtos.cs b/backend/Application/Dtos/SubmissionDtos.cs new file mode 100644 index 0000000..a15d469 --- /dev/null +++ b/backend/Application/Dtos/SubmissionDtos.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + +public sealed record CreateSubmissionRequest( + [Required, StringLength(200, MinimumLength = 3)] string Title, + [Required, StringLength(4000, MinimumLength = 10)] string Description, + [Required] string ChallengeId, + [Required] string LocationId, + IReadOnlyList? Keywords); + +public sealed record UpdateSubmissionStatusRequest( + [Required] string Status); + +public sealed record SubmissionResponse( + string Id, + string ProblemId, + string Title, + string Description, + string ChallengeId, + string LocationId, + string AuthorId, + string Status, + IReadOnlyList Keywords, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); diff --git a/backend/Application/Mappings/SubmissionStatusWire.cs b/backend/Application/Mappings/SubmissionStatusWire.cs new file mode 100644 index 0000000..9e1fd03 --- /dev/null +++ b/backend/Application/Mappings/SubmissionStatusWire.cs @@ -0,0 +1,36 @@ +using TarninaHub.Domain.Enums; + +namespace TarninaHub.Application.Mappings; + +public static class SubmissionStatusWire +{ + public static string ToWire(this SubmissionStatus status) => status switch + { + SubmissionStatus.New => "new", + SubmissionStatus.Triaged => "triaged", + SubmissionStatus.Matched => "matched", + SubmissionStatus.InProgress => "in_progress", + SubmissionStatus.Closed => "closed", + SubmissionStatus.Rejected => "rejected", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; + + public static SubmissionStatus? Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value switch + { + "new" => SubmissionStatus.New, + "triaged" => SubmissionStatus.Triaged, + "matched" => SubmissionStatus.Matched, + "in_progress" => SubmissionStatus.InProgress, + "closed" => SubmissionStatus.Closed, + "rejected" => SubmissionStatus.Rejected, + _ => null, + }; + } +} diff --git a/backend/Application/Text/KeywordExtractor.cs b/backend/Application/Text/KeywordExtractor.cs new file mode 100644 index 0000000..db7b8d7 --- /dev/null +++ b/backend/Application/Text/KeywordExtractor.cs @@ -0,0 +1,70 @@ +using System.Text; + +namespace TarninaHub.Application.Text; + +public static class KeywordExtractor +{ + private static readonly HashSet StopWords = new(StringComparer.OrdinalIgnoreCase) + { + "i", "w", "we", "na", "z", "do", "nie", "że", "się", "jest", "to", "dla", "od", "po", + "o", "a", "ale", "jak", "co", "oraz", "przez", "przy", "tak", "już", "czy", "ich", + "the", "and", "for", "with", "this", "that", "are", "was", + }; + + public static IReadOnlyList Extract(string text, int max = 8) + { + if (string.IsNullOrWhiteSpace(text) || max <= 0) + { + return []; + } + + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var order = new List(); + + foreach (var rawToken in Tokenize(text)) + { + var token = rawToken.ToLowerInvariant(); + + if (token.Length < 3 || StopWords.Contains(token)) + { + continue; + } + + if (!counts.TryGetValue(token, out var count)) + { + order.Add(token); + } + + counts[token] = count + 1; + } + + return order + .OrderByDescending(token => counts[token]) + .ThenBy(token => order.IndexOf(token)) + .Take(max) + .ToList(); + } + + private static IEnumerable Tokenize(string text) + { + var buffer = new StringBuilder(); + + foreach (var character in text) + { + if (char.IsLetter(character)) + { + buffer.Append(character); + } + else if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + } + + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + } +} diff --git a/backend/Controllers/SubmissionsController.cs b/backend/Controllers/SubmissionsController.cs new file mode 100644 index 0000000..509b54d --- /dev/null +++ b/backend/Controllers/SubmissionsController.cs @@ -0,0 +1,137 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Mappings; +using TarninaHub.Application.Security; +using TarninaHub.Domain.Rules; + +namespace TarninaHub.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/submissions")] +[Authorize] +public class SubmissionsController : ControllerBase +{ + private readonly ISubmissionService _submissionService; + + public SubmissionsController(ISubmissionService submissionService) + { + _submissionService = submissionService; + } + + [HttpPost] + public async Task> Create( + CreateSubmissionRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var created = await _submissionService.CreateAsync(userId, request, cancellationToken); + + return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created); + } + + [HttpGet] + public async Task>> List( + [FromQuery] string? status, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + page = page < 1 ? 1 : page; + pageSize = pageSize is < 1 or > 100 ? 20 : pageSize; + + var result = await _submissionService.ListAsync( + userId, + IsStaff(), + status, + page, + pageSize, + cancellationToken); + + return Ok(result); + } + + [HttpGet("{submissionId}")] + public async Task> GetById( + string submissionId, + CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var submission = await _submissionService.GetAsync(submissionId, cancellationToken); + + if (submission is null) + { + return NotFound(); + } + + if (!IsStaff() && !string.Equals(submission.AuthorId, userId, StringComparison.Ordinal)) + { + return Forbid(); + } + + return Ok(submission); + } + + [HttpPut("{submissionId}/status")] + [Authorize(Policy = Policies.OperatorOrAdmin)] + public async Task> UpdateStatus( + string submissionId, + UpdateSubmissionStatusRequest request, + CancellationToken cancellationToken) + { + var current = await _submissionService.GetAsync(submissionId, cancellationToken); + + if (current is null) + { + return NotFound(); + } + + var from = SubmissionStatusWire.Parse(current.Status); + var to = SubmissionStatusWire.Parse(request.Status); + + if (to is null) + { + ModelState.AddModelError(nameof(request.Status), "Unknown status value."); + + return ValidationProblem(ModelState); + } + + if (from is null || !SubmissionWorkflow.CanTransition(from.Value, to.Value)) + { + ModelState.AddModelError( + nameof(request.Status), + $"Transition from '{current.Status}' to '{request.Status}' is not allowed."); + + return ValidationProblem(ModelState); + } + + var updated = await _submissionService.UpdateStatusAsync(submissionId, to.Value.ToWire(), cancellationToken); + + return updated is null ? NotFound() : Ok(updated); + } + + private bool IsStaff() => + User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin); +} diff --git a/backend/Domain/Rules/SubmissionWorkflow.cs b/backend/Domain/Rules/SubmissionWorkflow.cs new file mode 100644 index 0000000..5f966b2 --- /dev/null +++ b/backend/Domain/Rules/SubmissionWorkflow.cs @@ -0,0 +1,26 @@ +using TarninaHub.Domain.Enums; + +namespace TarninaHub.Domain.Rules; + +public static class SubmissionWorkflow +{ + private static readonly IReadOnlyDictionary Transitions = + new Dictionary + { + [SubmissionStatus.New] = [SubmissionStatus.Triaged, SubmissionStatus.Rejected], + [SubmissionStatus.Triaged] = [SubmissionStatus.Matched, SubmissionStatus.Rejected, SubmissionStatus.Closed], + [SubmissionStatus.Matched] = [SubmissionStatus.InProgress, SubmissionStatus.Closed, SubmissionStatus.Rejected], + [SubmissionStatus.InProgress] = [SubmissionStatus.Closed, SubmissionStatus.Rejected], + [SubmissionStatus.Closed] = [], + [SubmissionStatus.Rejected] = [], + }; + + public static bool CanTransition(SubmissionStatus from, SubmissionStatus to) => + from == to || Transitions[from].Contains(to); + + public static IReadOnlyList NextStates(SubmissionStatus from) => + Transitions[from]; + + public static bool IsTerminal(SubmissionStatus status) => + Transitions[status].Length == 0; +} diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index a372425..bcd8953 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using SurrealDb.Net; using TarninaHub.Application.Abstractions; using TarninaHub.Infrastructure.Auth; +using TarninaHub.Infrastructure.Events; using TarninaHub.Infrastructure.Persistence; using TarninaHub.Infrastructure.Settings; @@ -52,6 +53,8 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/backend/Infrastructure/Events/LoggingSubmissionEventPublisher.cs b/backend/Infrastructure/Events/LoggingSubmissionEventPublisher.cs new file mode 100644 index 0000000..972534b --- /dev/null +++ b/backend/Infrastructure/Events/LoggingSubmissionEventPublisher.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using TarninaHub.Application.Abstractions; + +namespace TarninaHub.Infrastructure.Events; + +/// +/// Placeholder publisher: logs domain events. Will be replaced/wired to +/// matchmaking (steps 14-15) and notifications (step 17). +/// +public sealed class LoggingSubmissionEventPublisher : ISubmissionEventPublisher +{ + private readonly ILogger _logger; + + public LoggingSubmissionEventPublisher(ILogger logger) + { + _logger = logger; + } + + public Task PublishAsync(SubmissionEvent submissionEvent, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Domain event {EventType} for {SubmissionId} (status: {Status}, actor: {ActorId})", + submissionEvent.Type, + submissionEvent.SubmissionId, + submissionEvent.Status, + submissionEvent.ActorId ?? "system"); + + return Task.CompletedTask; + } +} diff --git a/backend/Infrastructure/Persistence/SubmissionService.cs b/backend/Infrastructure/Persistence/SubmissionService.cs new file mode 100644 index 0000000..897a8af --- /dev/null +++ b/backend/Infrastructure/Persistence/SubmissionService.cs @@ -0,0 +1,227 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Text; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class SubmissionService : ISubmissionService +{ + private const string SelectFields = + "type::string(id) AS id, type::string(problem) AS problem, status, keywords, created_at, updated_at, " + + "problem.title AS title, problem.description AS description, " + + "type::string(problem.challenge) AS challenge, type::string(problem.location) AS location, " + + "type::string(problem.author) AS author"; + + private readonly ISurrealDbClient _client; + private readonly ISubmissionEventPublisher _events; + + public SubmissionService(ISurrealDbClient client, ISubmissionEventPublisher events) + { + _client = client; + _events = events; + } + + public async Task CreateAsync( + string authorId, + CreateSubmissionRequest request, + CancellationToken cancellationToken) + { + var problemId = Guid.NewGuid().ToString("N"); + var submissionId = Guid.NewGuid().ToString("N"); + var keywords = request.Keywords is { Count: > 0 } + ? request.Keywords + : KeywordExtractor.Extract(request.Description); + + var (challengeTable, challengeIdPart) = SurrealRecordId.Split(request.ChallengeId); + var (locationTable, locationIdPart) = SurrealRecordId.Split(request.LocationId); + var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId); + + var problemResponse = await _client.RawQuery( + "CREATE type::record('problem', $id) SET title = $title, description = $description, " + + "challenge = type::record($challengeTable, $challengeId), " + + "location = type::record($locationTable, $locationId), " + + "author = type::record($authorTable, $authorId), visibility = 'shared';", + new Dictionary + { + ["id"] = problemId, + ["title"] = request.Title, + ["description"] = request.Description, + ["challengeTable"] = challengeTable, + ["challengeId"] = challengeIdPart, + ["locationTable"] = locationTable, + ["locationId"] = locationIdPart, + ["authorTable"] = authorTable, + ["authorId"] = authorIdPart, + }, + cancellationToken); + problemResponse.EnsureAllOks(); + + var submissionResponse = await _client.RawQuery( + "CREATE type::record('submission', $id) SET problem = type::record('problem', $problemId), " + + "submitted_by = type::record($authorTable, $authorId), status = 'new', keywords = $keywords;", + new Dictionary + { + ["id"] = submissionId, + ["problemId"] = problemId, + ["authorTable"] = authorTable, + ["authorId"] = authorIdPart, + ["keywords"] = keywords, + }, + cancellationToken); + submissionResponse.EnsureAllOks(); + + var created = await LoadAsync($"submission:{submissionId}", cancellationToken) + ?? throw new InvalidOperationException("Submission could not be loaded after creation."); + + await _events.PublishAsync( + new SubmissionEvent("submission.created", created.Id, authorId, created.Status, DateTimeOffset.UtcNow), + cancellationToken); + + return created; + } + + public async Task> ListAsync( + string viewerId, + bool isStaff, + string? status, + int page, + int pageSize, + CancellationToken cancellationToken) + { + var where = BuildWhere(viewerId, isStaff, status, out var parameters); + + var countResponse = await _client.RawQuery( + $"SELECT count() AS count FROM submission{where} GROUP ALL;", + parameters, + cancellationToken); + countResponse.EnsureAllOks(); + + var totals = countResponse.GetValue>(0) ?? []; + var total = totals.Count > 0 ? totals[0].Count : 0; + + parameters["limit"] = pageSize; + parameters["offset"] = (page - 1) * pageSize; + + var listResponse = await _client.RawQuery( + $"SELECT {SelectFields} FROM submission{where} ORDER BY created_at DESC LIMIT $limit START $offset;", + parameters, + cancellationToken); + listResponse.EnsureAllOks(); + + var rows = listResponse.GetValue>(0) ?? []; + + return new PagedResponse( + rows.Select(Map).ToList(), + page, + pageSize, + total); + } + + public async Task GetAsync( + string submissionId, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(submissionId); + + var response = await _client.RawQuery( + $"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task UpdateStatusAsync( + string submissionId, + string newStatus, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(submissionId); + + var response = await _client.RawQuery( + "UPDATE type::record($table, $id) SET status = $status, updated_at = time::now(), " + + "version = version + 1; " + + $"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;", + new Dictionary + { + ["table"] = table, + ["id"] = id, + ["status"] = newStatus, + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + if (rows.Count == 0) + { + return null; + } + + var updated = Map(rows[0]); + + await _events.PublishAsync( + new SubmissionEvent("submission.status_changed", updated.Id, null, updated.Status, DateTimeOffset.UtcNow), + cancellationToken); + + return updated; + } + + private async Task LoadAsync(string submissionId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(submissionId); + + var response = await _client.RawQuery( + $"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + private static string BuildWhere( + string viewerId, + bool isStaff, + string? status, + out Dictionary parameters) + { + parameters = new Dictionary(); + var clauses = new List(); + + if (!isStaff) + { + var (viewerTable, viewerIdPart) = SurrealRecordId.Split(viewerId); + clauses.Add("submitted_by = type::record($viewerTable, $viewerId)"); + parameters["viewerTable"] = viewerTable; + parameters["viewerId"] = viewerIdPart; + } + + if (!string.IsNullOrWhiteSpace(status)) + { + clauses.Add("status = $status"); + parameters["status"] = status; + } + + return clauses.Count > 0 ? " WHERE " + string.Join(" AND ", clauses) : string.Empty; + } + + private static SubmissionResponse Map(SubmissionRow row) => new( + row.Id, + row.Problem, + row.Title, + row.Description, + row.Challenge, + row.Location, + row.Author, + row.Status, + row.Keywords, + row.CreatedAt, + row.UpdatedAt); +} diff --git a/backend/Infrastructure/Persistence/SurrealRows.cs b/backend/Infrastructure/Persistence/SurrealRows.cs index b3d74c6..e5f5678 100644 --- a/backend/Infrastructure/Persistence/SurrealRows.cs +++ b/backend/Infrastructure/Persistence/SurrealRows.cs @@ -34,3 +34,33 @@ internal sealed class ConsentRow [CborProperty("revoked_at")] public DateTimeOffset? RevokedAt { get; set; } } + +internal sealed class SubmissionRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("problem")] public string Problem { get; set; } = string.Empty; + + [CborProperty("status")] public string Status { get; set; } = string.Empty; + + [CborProperty("keywords")] public List Keywords { get; set; } = []; + + [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } + + [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } + + [CborProperty("title")] public string Title { get; set; } = string.Empty; + + [CborProperty("description")] public string Description { get; set; } = string.Empty; + + [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; + + [CborProperty("location")] public string Location { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; +} + +internal sealed class CountRow +{ + [CborProperty("count")] public int Count { get; set; } +}