feat(backend): add knowledge base, innovation library and trends modules
Add knowledge item and innovation CRUD with search/filtering, published-only visibility for non-staff, admin publish/update, challenge dictionary, problem aggregation with trends (admin only) and canonical embedding text preparation for matchmaking.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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"] = 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"] = 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 KnowledgeItemResponse Map(KnowledgeItemRow row) => new(
|
||||
row.Id,
|
||||
row.Title,
|
||||
row.Kind,
|
||||
NormalizeRecordId(row.Challenge),
|
||||
row.SourceUrl,
|
||||
row.Attachments.Select(item => (IReadOnlyDictionary<string, object?>)item).ToList(),
|
||||
row.Published,
|
||||
row.UpdatedAt);
|
||||
|
||||
private static string? NormalizeRecordId(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value;
|
||||
}
|
||||
Reference in New Issue
Block a user