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:
Rafał Tyński
2026-10-04 02:32:33 +02:00
parent fee137aee6
commit bad37a6ab9
19 changed files with 1074 additions and 0 deletions
@@ -55,6 +55,10 @@ public static class DependencyInjection
services.AddSingleton<IAuditLogService, AuditLogService>();
services.AddSingleton<ISubmissionEventPublisher, LoggingSubmissionEventPublisher>();
services.AddSingleton<ISubmissionService, SubmissionService>();
services.AddSingleton<IChallengeService, ChallengeService>();
services.AddSingleton<IKnowledgeItemService, KnowledgeItemService>();
services.AddSingleton<IInnovationService, InnovationService>();
services.AddSingleton<ITrendService, TrendService>();
return services;
}
@@ -0,0 +1,29 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class ChallengeService : IChallengeService
{
private readonly ISurrealDbClient _client;
public ChallengeService(ISurrealDbClient client)
{
_client = client;
}
public async Task<IReadOnlyList<SocialChallengeResponse>> ListAsync(CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
"SELECT type::string(id) AS id, name, description, order FROM social_challenge ORDER BY order;",
cancellationToken: cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ChallengeRow>>(0) ?? [];
return rows
.Select(row => new SocialChallengeResponse(row.Id, row.Name, row.Description, row.Order))
.ToList();
}
}
@@ -0,0 +1,229 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class InnovationService : IInnovationService
{
private const string SelectFields =
"type::string(id) AS id, title, summary, description, type::string(challenge) AS challenge, " +
"stage, cost, contact, media, published, updated_at";
private readonly ISurrealDbClient _client;
public InnovationService(ISurrealDbClient client)
{
_client = client;
}
public async Task<IReadOnlyList<InnovationResponse>> ListAsync(
string? challengeId,
string? stage,
string? query,
bool onlyPublished,
CancellationToken cancellationToken)
{
var where = BuildFilter(challengeId, stage, query, onlyPublished, out var parameters);
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM innovation{where} ORDER BY updated_at DESC;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<InnovationRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
public async Task<InnovationResponse?> GetAsync(string innovationId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(innovationId);
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<InnovationRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<InnovationResponse> CreateAsync(
InnovationRequest request,
CancellationToken cancellationToken)
{
var id = Guid.NewGuid().ToString("N");
var (challengeTable, challengeId) = SurrealRecordId.Split(request.ChallengeId);
var parameters = new Dictionary<string, object?>
{
["id"] = id,
["title"] = request.Title,
["summary"] = request.Summary ?? string.Empty,
["description"] = request.Description ?? string.Empty,
["challengeTable"] = challengeTable,
["challengeId"] = challengeId,
["stage"] = request.Stage,
["media"] = request.Media ?? [],
["published"] = request.Published,
};
var assignments = new List<string>
{
"title = $title", "summary = $summary", "description = $description",
"challenge = type::record($challengeTable, $challengeId)", "stage = $stage",
"media = $media", "published = $published", "updated_at = time::now()",
};
AppendOptional(assignments, parameters, "cost", request.Cost);
AppendOptionalObject(assignments, parameters, "contact", request.Contact);
var response = await _client.RawQuery(
"CREATE type::record('innovation', $id) SET " + string.Join(", ", assignments) + "; " +
$"SELECT {SelectFields} FROM type::record('innovation', $id) LIMIT 1;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<InnovationRow>>(1) ?? [];
return Map(rows[0]);
}
public async Task<InnovationResponse?> UpdateAsync(
string innovationId,
InnovationRequest request,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(innovationId);
var (challengeTable, challengeId) = SurrealRecordId.Split(request.ChallengeId);
var parameters = new Dictionary<string, object?>
{
["table"] = table,
["id"] = id,
["title"] = request.Title,
["summary"] = request.Summary ?? string.Empty,
["description"] = request.Description ?? string.Empty,
["challengeTable"] = challengeTable,
["challengeId"] = challengeId,
["stage"] = request.Stage,
["media"] = request.Media ?? [],
["published"] = request.Published,
};
var assignments = new List<string>
{
"title = $title", "summary = $summary", "description = $description",
"challenge = type::record($challengeTable, $challengeId)", "stage = $stage",
"media = $media", "published = $published", "updated_at = time::now()",
};
AppendOptional(assignments, parameters, "cost", request.Cost, clearWhenMissing: true);
AppendOptionalObject(assignments, parameters, "contact", request.Contact, 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<InnovationRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
private static string BuildFilter(
string? challengeId,
string? stage,
string? query,
bool onlyPublished,
out Dictionary<string, object?> parameters)
{
parameters = new Dictionary<string, object?>();
var clauses = new List<string>();
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(stage))
{
clauses.Add("stage = $stage");
parameters["stage"] = stage;
}
if (!string.IsNullOrWhiteSpace(query))
{
clauses.Add(
"(string::contains(string::lowercase(title), string::lowercase($query)) " +
"OR string::contains(string::lowercase(summary), 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 AppendOptional(
List<string> assignments,
Dictionary<string, object?> parameters,
string field,
string? value,
bool clearWhenMissing = false)
{
if (!string.IsNullOrWhiteSpace(value))
{
assignments.Add($"{field} = $field_{field}");
parameters[$"field_{field}"] = value;
}
else if (clearWhenMissing)
{
assignments.Add($"{field} = NONE");
}
}
private static void AppendOptionalObject(
List<string> assignments,
Dictionary<string, object?> parameters,
string field,
IReadOnlyDictionary<string, object?>? value,
bool clearWhenMissing = false)
{
if (value is not null)
{
assignments.Add($"{field} = $field_{field}");
parameters[$"field_{field}"] = value;
}
else if (clearWhenMissing)
{
assignments.Add($"{field} = NONE");
}
}
private static InnovationResponse Map(InnovationRow row) => new(
row.Id,
row.Title,
row.Summary,
row.Description,
row.Challenge,
row.Stage,
row.Cost,
row.Contact is null ? null : row.Contact,
row.Media.Select(item => (IReadOnlyDictionary<string, object?>)item).ToList(),
row.Published,
row.UpdatedAt);
}
@@ -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;
}
@@ -64,3 +64,70 @@ internal sealed class CountRow
{
[CborProperty("count")] public int Count { get; set; }
}
internal sealed class ChallengeRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("name")] public string Name { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("order")] public int Order { get; set; }
}
internal sealed class KnowledgeItemRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("kind")] public string Kind { get; set; } = string.Empty;
[CborProperty("challenge")] public string? Challenge { get; set; }
[CborProperty("source_url")] public string? SourceUrl { get; set; }
[CborProperty("attachments")] public List<Dictionary<string, object?>> Attachments { get; set; } = [];
[CborProperty("published")] public bool Published { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
}
internal sealed class InnovationRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("summary")] public string Summary { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("stage")] public string Stage { get; set; } = string.Empty;
[CborProperty("cost")] public string? Cost { get; set; }
[CborProperty("contact")] public Dictionary<string, object?>? Contact { get; set; }
[CborProperty("media")] public List<Dictionary<string, object?>> Media { get; set; } = [];
[CborProperty("published")] public bool Published { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
}
internal sealed class TrendRow
{
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("count")] public int Count { get; set; }
}
internal sealed class TrendSubmissionRow
{
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
}
@@ -0,0 +1,79 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class TrendService : ITrendService
{
private readonly ISurrealDbClient _client;
public TrendService(ISurrealDbClient client)
{
_client = client;
}
public async Task<TrendsResponse> GetAsync(
string? challengeId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken)
{
var counts = await CountSubmissionsAsync(challengeId, from, to, cancellationToken);
var challenges = await LoadChallengesAsync(cancellationToken);
var points = challenges
.Where(challenge => string.IsNullOrWhiteSpace(challengeId)
|| string.Equals(challenge.Id, challengeId, StringComparison.Ordinal))
.Select(challenge => new TrendPoint(
challenge.Id,
challenge.Name,
counts.GetValueOrDefault(challenge.Id)))
.OrderByDescending(point => point.SubmissionCount)
.ToList();
return new TrendsResponse(from, to, points);
}
private async Task<Dictionary<string, int>> CountSubmissionsAsync(
string? challengeId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken)
{
var parameters = new Dictionary<string, object?> { ["from"] = from, ["to"] = to };
var clauses = new List<string> { "created_at >= $from", "created_at <= $to" };
if (!string.IsNullOrWhiteSpace(challengeId))
{
var (table, id) = SurrealRecordId.Split(challengeId);
clauses.Add("problem.challenge = type::record($challengeTable, $challengeId)");
parameters["challengeTable"] = table;
parameters["challengeId"] = id;
}
var response = await _client.RawQuery(
"SELECT type::string(problem.challenge) AS challenge FROM submission WHERE " +
string.Join(" AND ", clauses) + ";",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<TrendSubmissionRow>>(0) ?? [];
return rows
.Where(row => !string.IsNullOrWhiteSpace(row.Challenge))
.GroupBy(row => row.Challenge)
.ToDictionary(group => group.Key, group => group.Count());
}
private async Task<List<ChallengeRow>> LoadChallengesAsync(CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
"SELECT type::string(id) AS id, name, order FROM social_challenge ORDER BY order;",
cancellationToken: cancellationToken);
response.EnsureAllOks();
return response.GetValue<List<ChallengeRow>>(0) ?? [];
}
}