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