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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ActionResult<SubmissionResponse>> 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<ActionResult<PagedResponse<SubmissionResponse>>> 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<ActionResult<SubmissionResponse>> 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<ActionResult<SubmissionResponse>> 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using TarninaHub.Domain.Enums;
|
||||||
|
|
||||||
|
namespace TarninaHub.Domain.Rules;
|
||||||
|
|
||||||
|
public static class SubmissionWorkflow
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<SubmissionStatus, SubmissionStatus[]> Transitions =
|
||||||
|
new Dictionary<SubmissionStatus, SubmissionStatus[]>
|
||||||
|
{
|
||||||
|
[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<SubmissionStatus> NextStates(SubmissionStatus from) =>
|
||||||
|
Transitions[from];
|
||||||
|
|
||||||
|
public static bool IsTerminal(SubmissionStatus status) =>
|
||||||
|
Transitions[status].Length == 0;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using SurrealDb.Net;
|
using SurrealDb.Net;
|
||||||
using TarninaHub.Application.Abstractions;
|
using TarninaHub.Application.Abstractions;
|
||||||
using TarninaHub.Infrastructure.Auth;
|
using TarninaHub.Infrastructure.Auth;
|
||||||
|
using TarninaHub.Infrastructure.Events;
|
||||||
using TarninaHub.Infrastructure.Persistence;
|
using TarninaHub.Infrastructure.Persistence;
|
||||||
using TarninaHub.Infrastructure.Settings;
|
using TarninaHub.Infrastructure.Settings;
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IUserService, UserService>();
|
services.AddSingleton<IUserService, UserService>();
|
||||||
services.AddSingleton<IConsentService, ConsentService>();
|
services.AddSingleton<IConsentService, ConsentService>();
|
||||||
services.AddSingleton<IAuditLogService, AuditLogService>();
|
services.AddSingleton<IAuditLogService, AuditLogService>();
|
||||||
|
services.AddSingleton<ISubmissionEventPublisher, LoggingSubmissionEventPublisher>();
|
||||||
|
services.AddSingleton<ISubmissionService, SubmissionService>();
|
||||||
|
|
||||||
return services;
|
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; }
|
[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