Files
Rafał Tyński cadc321d96 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.
2026-10-04 02:22:00 +02:00

228 lines
8.1 KiB
C#

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);
}