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:
@@ -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<IUserService, UserService>();
|
||||
services.AddSingleton<IConsentService, ConsentService>();
|
||||
services.AddSingleton<IAuditLogService, AuditLogService>();
|
||||
services.AddSingleton<ISubmissionEventPublisher, LoggingSubmissionEventPublisher>();
|
||||
services.AddSingleton<ISubmissionService, SubmissionService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder publisher: logs domain events. Will be replaced/wired to
|
||||
/// matchmaking (steps 14-15) and notifications (step 17).
|
||||
/// </summary>
|
||||
public sealed class LoggingSubmissionEventPublisher : ISubmissionEventPublisher
|
||||
{
|
||||
private readonly ILogger<LoggingSubmissionEventPublisher> _logger;
|
||||
|
||||
public LoggingSubmissionEventPublisher(ILogger<LoggingSubmissionEventPublisher> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<SubmissionResponse> 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<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["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<PagedResponse<SubmissionResponse>> 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<List<CountRow>>(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<List<SubmissionRow>>(0) ?? [];
|
||||
|
||||
return new PagedResponse<SubmissionResponse>(
|
||||
rows.Select(Map).ToList(),
|
||||
page,
|
||||
pageSize,
|
||||
total);
|
||||
}
|
||||
|
||||
public async Task<SubmissionResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<SubmissionRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async Task<SubmissionResponse?> 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<string, object?>
|
||||
{
|
||||
["table"] = table,
|
||||
["id"] = id,
|
||||
["status"] = newStatus,
|
||||
},
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<SubmissionRow>>(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<SubmissionResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<SubmissionRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
private static string BuildWhere(
|
||||
string viewerId,
|
||||
bool isStaff,
|
||||
string? status,
|
||||
out Dictionary<string, object?> parameters)
|
||||
{
|
||||
parameters = new Dictionary<string, object?>();
|
||||
var clauses = new List<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<string> 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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user