using SurrealDb.Net; using TarninaHub.Application.Abstractions; using TarninaHub.Application.Dtos; namespace TarninaHub.Infrastructure.Persistence; public sealed class KnowledgeItemService : IKnowledgeItemService { private const string SelectFields = "type::string(id) AS id, title, kind, type::string(challenge) AS challenge, " + "source_url, attachments, published, updated_at"; private readonly ISurrealDbClient _client; public KnowledgeItemService(ISurrealDbClient client) { _client = client; } public async Task> ListAsync( string? kind, string? challengeId, string? query, bool onlyPublished, CancellationToken cancellationToken) { var where = BuildFilter(kind, challengeId, query, onlyPublished, out var parameters); var response = await _client.RawQuery( $"SELECT {SelectFields} FROM knowledge_item{where} ORDER BY updated_at DESC;", parameters, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(0) ?? []; return rows.Select(Map).ToList(); } public async Task GetAsync(string knowledgeItemId, CancellationToken cancellationToken) { var (table, id) = SurrealRecordId.Split(knowledgeItemId); 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 CreateAsync( KnowledgeItemRequest request, CancellationToken cancellationToken) { var id = Guid.NewGuid().ToString("N"); var parameters = new Dictionary { ["id"] = id, ["title"] = request.Title, ["kind"] = request.Kind, ["attachments"] = request.Attachments ?? [], ["published"] = request.Published, }; var assignments = new List { "title = $title", "kind = $kind", "attachments = $attachments", "published = $published", "updated_at = time::now()", }; AppendChallengeAssignment(request.ChallengeId, assignments, parameters); AppendSourceUrlAssignment(request.SourceUrl, assignments, parameters); var response = await _client.RawQuery( "CREATE type::record('knowledge_item', $id) SET " + string.Join(", ", assignments) + "; " + $"SELECT {SelectFields} FROM type::record('knowledge_item', $id) LIMIT 1;", parameters, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(1) ?? []; return Map(rows[0]); } public async Task UpdateAsync( string knowledgeItemId, KnowledgeItemRequest request, CancellationToken cancellationToken) { var (table, id) = SurrealRecordId.Split(knowledgeItemId); var parameters = new Dictionary { ["table"] = table, ["id"] = id, ["title"] = request.Title, ["kind"] = request.Kind, ["attachments"] = request.Attachments ?? [], ["published"] = request.Published, }; var assignments = new List { "title = $title", "kind = $kind", "attachments = $attachments", "published = $published", "updated_at = time::now()", }; AppendChallengeAssignment(request.ChallengeId, assignments, parameters, clearWhenMissing: true); AppendSourceUrlAssignment(request.SourceUrl, assignments, parameters, clearWhenMissing: true); var response = await _client.RawQuery( $"UPDATE type::record($table, $id) SET {string.Join(", ", assignments)}; " + $"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;", parameters, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(1) ?? []; return rows.Count > 0 ? Map(rows[0]) : null; } public async Task PublishAsync( string knowledgeItemId, CancellationToken cancellationToken) { var (table, id) = SurrealRecordId.Split(knowledgeItemId); var response = await _client.RawQuery( "UPDATE type::record($table, $id) SET published = true, updated_at = time::now(); " + $"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;", new Dictionary { ["table"] = table, ["id"] = id }, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(1) ?? []; return rows.Count > 0 ? Map(rows[0]) : null; } private static string BuildFilter( string? kind, string? challengeId, string? query, bool onlyPublished, out Dictionary parameters) { parameters = new Dictionary(); var clauses = new List(); if (!string.IsNullOrWhiteSpace(kind)) { clauses.Add("kind = $kind"); parameters["kind"] = kind; } if (!string.IsNullOrWhiteSpace(challengeId)) { var (table, id) = SurrealRecordId.Split(challengeId); clauses.Add("challenge = type::record($challengeTable, $challengeId)"); parameters["challengeTable"] = table; parameters["challengeId"] = id; } if (!string.IsNullOrWhiteSpace(query)) { clauses.Add("string::contains(string::lowercase(title), string::lowercase($query))"); parameters["query"] = query; } if (onlyPublished) { clauses.Add("published = true"); } return clauses.Count > 0 ? " WHERE " + string.Join(" AND ", clauses) : string.Empty; } private static void AppendChallengeAssignment( string? challengeId, List assignments, Dictionary parameters, bool clearWhenMissing = false) { if (!string.IsNullOrWhiteSpace(challengeId)) { var (table, id) = SurrealRecordId.Split(challengeId); assignments.Add("challenge = type::record($challengeTable, $challengeId)"); parameters["challengeTable"] = table; parameters["challengeId"] = id; } else if (clearWhenMissing) { assignments.Add("challenge = NONE"); } } private static void AppendSourceUrlAssignment( string? sourceUrl, List assignments, Dictionary parameters, bool clearWhenMissing = false) { if (!string.IsNullOrWhiteSpace(sourceUrl)) { assignments.Add("source_url = $sourceUrl"); parameters["sourceUrl"] = sourceUrl; } else if (clearWhenMissing) { assignments.Add("source_url = NONE"); } } private static KnowledgeItemResponse Map(KnowledgeItemRow row) => new( row.Id, row.Title, row.Kind, NormalizeRecordId(row.Challenge), row.SourceUrl, row.Attachments.Select(item => (IReadOnlyDictionary)item).ToList(), row.Published, row.UpdatedAt); private static string? NormalizeRecordId(string? value) => string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value; }