diff --git a/backend/Application/Abstractions/IChallengeService.cs b/backend/Application/Abstractions/IChallengeService.cs new file mode 100644 index 0000000..40601a2 --- /dev/null +++ b/backend/Application/Abstractions/IChallengeService.cs @@ -0,0 +1,8 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IChallengeService +{ + Task> ListAsync(CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/IInnovationService.cs b/backend/Application/Abstractions/IInnovationService.cs new file mode 100644 index 0000000..6324fd9 --- /dev/null +++ b/backend/Application/Abstractions/IInnovationService.cs @@ -0,0 +1,22 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IInnovationService +{ + Task> ListAsync( + string? challengeId, + string? stage, + string? query, + bool onlyPublished, + CancellationToken cancellationToken); + + Task GetAsync(string innovationId, CancellationToken cancellationToken); + + Task CreateAsync(InnovationRequest request, CancellationToken cancellationToken); + + Task UpdateAsync( + string innovationId, + InnovationRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/IKnowledgeItemService.cs b/backend/Application/Abstractions/IKnowledgeItemService.cs new file mode 100644 index 0000000..0f960b7 --- /dev/null +++ b/backend/Application/Abstractions/IKnowledgeItemService.cs @@ -0,0 +1,24 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IKnowledgeItemService +{ + Task> ListAsync( + string? kind, + string? challengeId, + string? query, + bool onlyPublished, + CancellationToken cancellationToken); + + Task GetAsync(string knowledgeItemId, CancellationToken cancellationToken); + + Task CreateAsync(KnowledgeItemRequest request, CancellationToken cancellationToken); + + Task UpdateAsync( + string knowledgeItemId, + KnowledgeItemRequest request, + CancellationToken cancellationToken); + + Task PublishAsync(string knowledgeItemId, CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/ITrendService.cs b/backend/Application/Abstractions/ITrendService.cs new file mode 100644 index 0000000..930025b --- /dev/null +++ b/backend/Application/Abstractions/ITrendService.cs @@ -0,0 +1,12 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface ITrendService +{ + Task GetAsync( + string? challengeId, + DateTimeOffset from, + DateTimeOffset to, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Dtos/InnovationDtos.cs b/backend/Application/Dtos/InnovationDtos.cs new file mode 100644 index 0000000..abf1db2 --- /dev/null +++ b/backend/Application/Dtos/InnovationDtos.cs @@ -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? Contact, + IReadOnlyList>? Media, + bool Published); + +public sealed record InnovationResponse( + string Id, + string Title, + string Summary, + string Description, + string ChallengeId, + string Stage, + string? Cost, + IReadOnlyDictionary? Contact, + IReadOnlyList> Media, + bool Published, + DateTimeOffset? UpdatedAt); diff --git a/backend/Application/Dtos/KnowledgeDtos.cs b/backend/Application/Dtos/KnowledgeDtos.cs new file mode 100644 index 0000000..508cd9b --- /dev/null +++ b/backend/Application/Dtos/KnowledgeDtos.cs @@ -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>? Attachments, + bool Published); + +public sealed record KnowledgeItemResponse( + string Id, + string Title, + string Kind, + string? ChallengeId, + string? SourceUrl, + IReadOnlyList> Attachments, + bool Published, + DateTimeOffset? UpdatedAt); + +public sealed record SocialChallengeResponse( + string Id, + string Name, + string Description, + int Order); diff --git a/backend/Application/Dtos/TrendsDtos.cs b/backend/Application/Dtos/TrendsDtos.cs new file mode 100644 index 0000000..093c9a6 --- /dev/null +++ b/backend/Application/Dtos/TrendsDtos.cs @@ -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 Points); diff --git a/backend/Application/Mappings/InnovationStageWire.cs b/backend/Application/Mappings/InnovationStageWire.cs new file mode 100644 index 0000000..2cd3e79 --- /dev/null +++ b/backend/Application/Mappings/InnovationStageWire.cs @@ -0,0 +1,29 @@ +using TarninaHub.Domain.Enums; + +namespace TarninaHub.Application.Mappings; + +public static class InnovationStageWire +{ + private static readonly Dictionary 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; +} diff --git a/backend/Application/Text/EmbeddingTextBuilder.cs b/backend/Application/Text/EmbeddingTextBuilder.cs new file mode 100644 index 0000000..03fdc8a --- /dev/null +++ b/backend/Application/Text/EmbeddingTextBuilder.cs @@ -0,0 +1,35 @@ +namespace TarninaHub.Application.Text; + +/// +/// Builds the canonical text used to compute an innovation embedding +/// (matchmaking, steps 14-15). +/// +public static class EmbeddingTextBuilder +{ + public static string Build(string title, string? summary, string? description, string? challengeName = null) + { + var parts = new List(); + + 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); + } +} diff --git a/backend/Controllers/InnovationsController.cs b/backend/Controllers/InnovationsController.cs new file mode 100644 index 0000000..1526d95 --- /dev/null +++ b/backend/Controllers/InnovationsController.cs @@ -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>> 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> 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> 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> 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); +} diff --git a/backend/Controllers/KnowledgeItemsController.cs b/backend/Controllers/KnowledgeItemsController.cs new file mode 100644 index 0000000..73960a0 --- /dev/null +++ b/backend/Controllers/KnowledgeItemsController.cs @@ -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>> 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> 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> 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> 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> 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); +} diff --git a/backend/Controllers/SocialChallengesController.cs b/backend/Controllers/SocialChallengesController.cs new file mode 100644 index 0000000..a048530 --- /dev/null +++ b/backend/Controllers/SocialChallengesController.cs @@ -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>> List( + CancellationToken cancellationToken) + { + var challenges = await _challenges.ListAsync(cancellationToken); + + return Ok(challenges); + } +} diff --git a/backend/Controllers/TrendsController.cs b/backend/Controllers/TrendsController.cs new file mode 100644 index 0000000..624d295 --- /dev/null +++ b/backend/Controllers/TrendsController.cs @@ -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> 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); + } +} diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index bcd8953..3ea658e 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -55,6 +55,10 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/backend/Infrastructure/Persistence/ChallengeService.cs b/backend/Infrastructure/Persistence/ChallengeService.cs new file mode 100644 index 0000000..527eccf --- /dev/null +++ b/backend/Infrastructure/Persistence/ChallengeService.cs @@ -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> 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>(0) ?? []; + + return rows + .Select(row => new SocialChallengeResponse(row.Id, row.Name, row.Description, row.Order)) + .ToList(); + } +} diff --git a/backend/Infrastructure/Persistence/InnovationService.cs b/backend/Infrastructure/Persistence/InnovationService.cs new file mode 100644 index 0000000..989921f --- /dev/null +++ b/backend/Infrastructure/Persistence/InnovationService.cs @@ -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> 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>(0) ?? []; + + return rows.Select(Map).ToList(); + } + + public async Task 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 { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task CreateAsync( + InnovationRequest request, + CancellationToken cancellationToken) + { + var id = Guid.NewGuid().ToString("N"); + var (challengeTable, challengeId) = SurrealRecordId.Split(request.ChallengeId); + + var parameters = new Dictionary + { + ["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 + { + "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>(1) ?? []; + + return Map(rows[0]); + } + + public async Task UpdateAsync( + string innovationId, + InnovationRequest request, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(innovationId); + var (challengeTable, challengeId) = SurrealRecordId.Split(request.ChallengeId); + + var parameters = new Dictionary + { + ["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 + { + "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>(1) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + private static string BuildFilter( + string? challengeId, + string? stage, + string? query, + bool onlyPublished, + out Dictionary parameters) + { + parameters = new Dictionary(); + var clauses = new List(); + + 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 assignments, + Dictionary 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 assignments, + Dictionary parameters, + string field, + IReadOnlyDictionary? 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)item).ToList(), + row.Published, + row.UpdatedAt); +} diff --git a/backend/Infrastructure/Persistence/KnowledgeItemService.cs b/backend/Infrastructure/Persistence/KnowledgeItemService.cs new file mode 100644 index 0000000..b0d917b --- /dev/null +++ b/backend/Infrastructure/Persistence/KnowledgeItemService.cs @@ -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> 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>(0) ?? []; + + return rows.Select(Map).ToList(); + } + + public async Task 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 { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task CreateAsync( + KnowledgeItemRequest request, + CancellationToken cancellationToken) + { + var id = Guid.NewGuid().ToString("N"); + var parameters = new Dictionary + { + ["id"] = id, + ["title"] = request.Title, + ["kind"] = request.Kind, + ["attachments"] = request.Attachments ?? [], + ["published"] = request.Published, + }; + + var assignments = new List + { + "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>(1) ?? []; + + return Map(rows[0]); + } + + public async Task UpdateAsync( + string knowledgeItemId, + KnowledgeItemRequest request, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(knowledgeItemId); + var parameters = new Dictionary + { + ["table"] = table, + ["id"] = id, + ["title"] = request.Title, + ["kind"] = request.Kind, + ["attachments"] = request.Attachments ?? [], + ["published"] = request.Published, + }; + + var assignments = new List + { + "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>(1) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task 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 { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + private static string BuildFilter( + string? kind, + string? challengeId, + string? query, + bool onlyPublished, + out Dictionary parameters) + { + parameters = new Dictionary(); + var clauses = new List(); + + 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 assignments, + Dictionary 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 assignments, + Dictionary 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)item).ToList(), + row.Published, + row.UpdatedAt); + + private static string? NormalizeRecordId(string? value) => + string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value; +} diff --git a/backend/Infrastructure/Persistence/SurrealRows.cs b/backend/Infrastructure/Persistence/SurrealRows.cs index e5f5678..1c72c27 100644 --- a/backend/Infrastructure/Persistence/SurrealRows.cs +++ b/backend/Infrastructure/Persistence/SurrealRows.cs @@ -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> 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? Contact { get; set; } + + [CborProperty("media")] public List> 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; +} diff --git a/backend/Infrastructure/Persistence/TrendService.cs b/backend/Infrastructure/Persistence/TrendService.cs new file mode 100644 index 0000000..e380228 --- /dev/null +++ b/backend/Infrastructure/Persistence/TrendService.cs @@ -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 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> CountSubmissionsAsync( + string? challengeId, + DateTimeOffset from, + DateTimeOffset to, + CancellationToken cancellationToken) + { + var parameters = new Dictionary { ["from"] = from, ["to"] = to }; + var clauses = new List { "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>(0) ?? []; + + return rows + .Where(row => !string.IsNullOrWhiteSpace(row.Challenge)) + .GroupBy(row => row.Challenge) + .ToDictionary(group => group.Key, group => group.Count()); + } + + private async Task> 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>(0) ?? []; + } +}