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
@@ -0,0 +1,8 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IChallengeService
{
Task<IReadOnlyList<SocialChallengeResponse>> ListAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IInnovationService
{
Task<IReadOnlyList<InnovationResponse>> ListAsync(
string? challengeId,
string? stage,
string? query,
bool onlyPublished,
CancellationToken cancellationToken);
Task<InnovationResponse?> GetAsync(string innovationId, CancellationToken cancellationToken);
Task<InnovationResponse> CreateAsync(InnovationRequest request, CancellationToken cancellationToken);
Task<InnovationResponse?> UpdateAsync(
string innovationId,
InnovationRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,24 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IKnowledgeItemService
{
Task<IReadOnlyList<KnowledgeItemResponse>> ListAsync(
string? kind,
string? challengeId,
string? query,
bool onlyPublished,
CancellationToken cancellationToken);
Task<KnowledgeItemResponse?> GetAsync(string knowledgeItemId, CancellationToken cancellationToken);
Task<KnowledgeItemResponse> CreateAsync(KnowledgeItemRequest request, CancellationToken cancellationToken);
Task<KnowledgeItemResponse?> UpdateAsync(
string knowledgeItemId,
KnowledgeItemRequest request,
CancellationToken cancellationToken);
Task<KnowledgeItemResponse?> PublishAsync(string knowledgeItemId, CancellationToken cancellationToken);
}
@@ -0,0 +1,12 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface ITrendService
{
Task<TrendsResponse> GetAsync(
string? challengeId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken);
}
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record InnovationRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[StringLength(2000)] string? Summary,
[StringLength(8000)] string? Description,
[Required] string ChallengeId,
[Required] string Stage,
string? Cost,
IReadOnlyDictionary<string, object?>? Contact,
IReadOnlyList<IReadOnlyDictionary<string, object?>>? Media,
bool Published);
public sealed record InnovationResponse(
string Id,
string Title,
string Summary,
string Description,
string ChallengeId,
string Stage,
string? Cost,
IReadOnlyDictionary<string, object?>? Contact,
IReadOnlyList<IReadOnlyDictionary<string, object?>> Media,
bool Published,
DateTimeOffset? UpdatedAt);
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record KnowledgeItemRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[Required] string Kind,
string? ChallengeId,
string? SourceUrl,
IReadOnlyList<IReadOnlyDictionary<string, object?>>? Attachments,
bool Published);
public sealed record KnowledgeItemResponse(
string Id,
string Title,
string Kind,
string? ChallengeId,
string? SourceUrl,
IReadOnlyList<IReadOnlyDictionary<string, object?>> Attachments,
bool Published,
DateTimeOffset? UpdatedAt);
public sealed record SocialChallengeResponse(
string Id,
string Name,
string Description,
int Order);
+11
View File
@@ -0,0 +1,11 @@
namespace TarninaHub.Application.Dtos;
public sealed record TrendPoint(
string ChallengeId,
string ChallengeName,
int SubmissionCount);
public sealed record TrendsResponse(
DateTimeOffset From,
DateTimeOffset To,
IReadOnlyList<TrendPoint> Points);
@@ -0,0 +1,29 @@
using TarninaHub.Domain.Enums;
namespace TarninaHub.Application.Mappings;
public static class InnovationStageWire
{
private static readonly Dictionary<string, InnovationStage> ByWire = new(StringComparer.Ordinal)
{
["idea"] = InnovationStage.Idea,
["pilot"] = InnovationStage.Pilot,
["scaling"] = InnovationStage.Scaling,
["service"] = InnovationStage.Service,
};
public static bool IsValid(string? value) =>
!string.IsNullOrWhiteSpace(value) && ByWire.ContainsKey(value);
public static string ToWire(this InnovationStage stage) => stage switch
{
InnovationStage.Idea => "idea",
InnovationStage.Pilot => "pilot",
InnovationStage.Scaling => "scaling",
InnovationStage.Service => "service",
_ => throw new ArgumentOutOfRangeException(nameof(stage), stage, null),
};
public static InnovationStage? Parse(string? value) =>
value is not null && ByWire.TryGetValue(value, out var stage) ? stage : null;
}
@@ -0,0 +1,35 @@
namespace TarninaHub.Application.Text;
/// <summary>
/// Builds the canonical text used to compute an innovation embedding
/// (matchmaking, steps 14-15).
/// </summary>
public static class EmbeddingTextBuilder
{
public static string Build(string title, string? summary, string? description, string? challengeName = null)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(title))
{
parts.Add(title.Trim());
}
if (!string.IsNullOrWhiteSpace(summary))
{
parts.Add(summary.Trim());
}
if (!string.IsNullOrWhiteSpace(description))
{
parts.Add(description.Trim());
}
if (!string.IsNullOrWhiteSpace(challengeName))
{
parts.Add(challengeName.Trim());
}
return string.Join("\n", parts);
}
}