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,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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Mappings;
|
||||
using TarninaHub.Application.Security;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/innovations")]
|
||||
[Authorize]
|
||||
public class InnovationsController : ControllerBase
|
||||
{
|
||||
private readonly IInnovationService _innovations;
|
||||
|
||||
public InnovationsController(IInnovationService innovations)
|
||||
{
|
||||
_innovations = innovations;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<InnovationResponse>>> List(
|
||||
[FromQuery] string? challengeId,
|
||||
[FromQuery] string? stage,
|
||||
[FromQuery] string? q,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var innovations = await _innovations.ListAsync(challengeId, stage, q, !IsStaff(), cancellationToken);
|
||||
|
||||
return Ok(innovations);
|
||||
}
|
||||
|
||||
[HttpGet("{innovationId}")]
|
||||
public async Task<ActionResult<InnovationResponse>> GetById(
|
||||
string innovationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var innovation = await _innovations.GetAsync(innovationId, cancellationToken);
|
||||
|
||||
return innovation is null ? NotFound() : Ok(innovation);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = Policies.OperatorOrAdmin)]
|
||||
public async Task<ActionResult<InnovationResponse>> Create(
|
||||
InnovationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ValidateStage(request.Stage))
|
||||
{
|
||||
return InvalidStage();
|
||||
}
|
||||
|
||||
var created = await _innovations.CreateAsync(request, cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { innovationId = created.Id }, created);
|
||||
}
|
||||
|
||||
[HttpPut("{innovationId}")]
|
||||
[Authorize(Policy = Policies.OperatorOrAdmin)]
|
||||
public async Task<ActionResult<InnovationResponse>> Update(
|
||||
string innovationId,
|
||||
InnovationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ValidateStage(request.Stage))
|
||||
{
|
||||
return InvalidStage();
|
||||
}
|
||||
|
||||
var updated = await _innovations.UpdateAsync(innovationId, request, cancellationToken);
|
||||
|
||||
return updated is null ? NotFound() : Ok(updated);
|
||||
}
|
||||
|
||||
private bool ValidateStage(string? stage) => InnovationStageWire.IsValid(stage);
|
||||
|
||||
private ActionResult InvalidStage()
|
||||
{
|
||||
ModelState.AddModelError(nameof(InnovationRequest.Stage),
|
||||
"Stage must be one of: idea, pilot, scaling, service.");
|
||||
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
private bool IsStaff() =>
|
||||
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Security;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/knowledge-items")]
|
||||
[Authorize]
|
||||
public class KnowledgeItemsController : ControllerBase
|
||||
{
|
||||
private readonly IKnowledgeItemService _knowledgeItems;
|
||||
|
||||
public KnowledgeItemsController(IKnowledgeItemService knowledgeItems)
|
||||
{
|
||||
_knowledgeItems = knowledgeItems;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<KnowledgeItemResponse>>> List(
|
||||
[FromQuery] string? kind,
|
||||
[FromQuery] string? challengeId,
|
||||
[FromQuery] string? q,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await _knowledgeItems.ListAsync(kind, challengeId, q, !IsStaff(), cancellationToken);
|
||||
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpGet("{knowledgeItemId}")]
|
||||
public async Task<ActionResult<KnowledgeItemResponse>> GetById(
|
||||
string knowledgeItemId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await _knowledgeItems.GetAsync(knowledgeItemId, cancellationToken);
|
||||
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = Policies.OperatorOrAdmin)]
|
||||
public async Task<ActionResult<KnowledgeItemResponse>> Create(
|
||||
KnowledgeItemRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var created = await _knowledgeItems.CreateAsync(request, cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { knowledgeItemId = created.Id }, created);
|
||||
}
|
||||
|
||||
[HttpPut("{knowledgeItemId}")]
|
||||
[Authorize(Policy = Policies.OperatorOrAdmin)]
|
||||
public async Task<ActionResult<KnowledgeItemResponse>> Update(
|
||||
string knowledgeItemId,
|
||||
KnowledgeItemRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var updated = await _knowledgeItems.UpdateAsync(knowledgeItemId, request, cancellationToken);
|
||||
|
||||
return updated is null ? NotFound() : Ok(updated);
|
||||
}
|
||||
|
||||
[HttpPost("{knowledgeItemId}/publish")]
|
||||
[Authorize(Policy = Policies.OperatorOrAdmin)]
|
||||
public async Task<ActionResult<KnowledgeItemResponse>> Publish(
|
||||
string knowledgeItemId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var published = await _knowledgeItems.PublishAsync(knowledgeItemId, cancellationToken);
|
||||
|
||||
return published is null ? NotFound() : Ok(published);
|
||||
}
|
||||
|
||||
private bool IsStaff() =>
|
||||
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Security;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/social-challenges")]
|
||||
[Authorize]
|
||||
public class SocialChallengesController : ControllerBase
|
||||
{
|
||||
private readonly IChallengeService _challenges;
|
||||
|
||||
public SocialChallengesController(IChallengeService challenges)
|
||||
{
|
||||
_challenges = challenges;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<SocialChallengeResponse>>> List(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var challenges = await _challenges.ListAsync(cancellationToken);
|
||||
|
||||
return Ok(challenges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Security;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/trends")]
|
||||
[Authorize(Policy = Policies.OperatorOrAdmin)]
|
||||
public class TrendsController : ControllerBase
|
||||
{
|
||||
private readonly ITrendService _trends;
|
||||
|
||||
public TrendsController(ITrendService trends)
|
||||
{
|
||||
_trends = trends;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<TrendsResponse>> Get(
|
||||
[FromQuery] string? challengeId,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rangeEnd = to ?? DateTimeOffset.UtcNow;
|
||||
var rangeStart = from ?? rangeEnd.AddDays(-30);
|
||||
|
||||
var trends = await _trends.GetAsync(challengeId, rangeStart, rangeEnd, cancellationToken);
|
||||
|
||||
return Ok(trends);
|
||||
}
|
||||
}
|
||||
@@ -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) ?? [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user