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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using TarninaHub.Application.Dtos;
|
||||
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public interface ISubmissionService
|
||||
{
|
||||
Task<SubmissionResponse> CreateAsync(
|
||||
string authorId,
|
||||
CreateSubmissionRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<PagedResponse<SubmissionResponse>> ListAsync(
|
||||
string viewerId,
|
||||
bool isStaff,
|
||||
string? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<SubmissionResponse?> GetAsync(
|
||||
string submissionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<SubmissionResponse?> UpdateStatusAsync(
|
||||
string submissionId,
|
||||
string newStatus,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TarninaHub.Application.Dtos;
|
||||
|
||||
public sealed record PagedResponse<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int Total);
|
||||
@@ -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<string>? 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<string> Keywords,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text;
|
||||
|
||||
namespace TarninaHub.Application.Text;
|
||||
|
||||
public static class KeywordExtractor
|
||||
{
|
||||
private static readonly HashSet<string> 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<string> Extract(string text, int max = 8)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || max <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var order = new List<string>();
|
||||
|
||||
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<string> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user