Files
Hy2026/backend/Infrastructure/Persistence/KnowledgeItemService.cs
T
Rafał Tyński fa1199539b feat(backend): add communication, tester, admin and middleman modules
Implement step 17 modules: threads/messages, notifications with automation (submission.created, idea.created, grant_call.opened), innovation test feedback, admin panel (submissions, verify, metrics, grant call status), Middleman adaptation and partnerships. Split multi-type files into one type per file and replace object-valued dictionaries with string-valued maps in data contracts (migrations 0006/0007).
2026-10-04 03:49:45 +02:00

236 lines
8.1 KiB
C#

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<IReadOnlyList<KnowledgeItemResponse>> 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<List<KnowledgeItemRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
public async Task<KnowledgeItemResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<KnowledgeItemRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<KnowledgeItemResponse> CreateAsync(
KnowledgeItemRequest request,
CancellationToken cancellationToken)
{
var id = Guid.NewGuid().ToString("N");
var parameters = new Dictionary<string, object?>
{
["id"] = id,
["title"] = request.Title,
["kind"] = request.Kind,
["attachments"] = ToConcreteList(request.Attachments),
["published"] = request.Published,
};
var assignments = new List<string>
{
"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<List<KnowledgeItemRow>>(1) ?? [];
return Map(rows[0]);
}
public async Task<KnowledgeItemResponse?> UpdateAsync(
string knowledgeItemId,
KnowledgeItemRequest request,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(knowledgeItemId);
var parameters = new Dictionary<string, object?>
{
["table"] = table,
["id"] = id,
["title"] = request.Title,
["kind"] = request.Kind,
["attachments"] = ToConcreteList(request.Attachments),
["published"] = request.Published,
};
var assignments = new List<string>
{
"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<List<KnowledgeItemRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<KnowledgeItemResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<KnowledgeItemRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
private static string BuildFilter(
string? kind,
string? challengeId,
string? query,
bool onlyPublished,
out Dictionary<string, object?> parameters)
{
parameters = new Dictionary<string, object?>();
var clauses = new List<string>();
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<string> assignments,
Dictionary<string, object?> 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<string> assignments,
Dictionary<string, object?> 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 List<Dictionary<string, string?>> ToConcreteList(
IReadOnlyList<IReadOnlyDictionary<string, string?>>? values) =>
values?.Select(item => item.ToDictionary(pair => pair.Key, pair => pair.Value)).ToList() ?? [];
private static KnowledgeItemResponse Map(KnowledgeItemRow row) => new(
row.Id,
row.Title,
row.Kind,
NormalizeRecordId(row.Challenge),
row.SourceUrl,
row.Attachments.Select(item => (IReadOnlyDictionary<string, string?>)item).ToList(),
row.Published,
row.UpdatedAt);
private static string? NormalizeRecordId(string? value) =>
string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value;
}