diff --git a/backend/Application/Abstractions/IdeaContracts.cs b/backend/Application/Abstractions/IdeaContracts.cs new file mode 100644 index 0000000..169f420 --- /dev/null +++ b/backend/Application/Abstractions/IdeaContracts.cs @@ -0,0 +1,59 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IIdeaService +{ + Task CreateAsync(string authorId, CreateIdeaRequest request, CancellationToken cancellationToken); + + Task GetAsync(string ideaId, CancellationToken cancellationToken); + + Task> ListForAuthorAsync(string authorId, CancellationToken cancellationToken); + + Task UpdateAsync(string ideaId, UpdateIdeaRequest request, CancellationToken cancellationToken); + + Task GetCardAsync(string ideaId, CancellationToken cancellationToken); + + Task UpdateCardAsync(string ideaId, IdeaCardRequest request, CancellationToken cancellationToken); +} + +public interface IIdeaAssistant +{ + Task AssistAsync( + string ideaId, + IdeaAssistRequest request, + CancellationToken cancellationToken); +} + +public interface IGrantCallService +{ + Task> ListAsync(string? status, CancellationToken cancellationToken); + + Task GetAsync(string grantCallId, CancellationToken cancellationToken); +} + +public enum CreateApplicationOutcome +{ + Created, + CallNotFound, + CallNotOpen, + IdeaNotFound, +} + +public sealed record CreateApplicationResult( + CreateApplicationOutcome Outcome, + ApplicationResponse? Application); + +public interface IApplicationService +{ + Task CreateAsync( + string userId, + CreateApplicationRequest request, + CancellationToken cancellationToken); + + Task GetAsync(string applicationId, CancellationToken cancellationToken); + + Task> ListForAuthorAsync( + string authorId, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Dtos/ApplicationDtos.cs b/backend/Application/Dtos/ApplicationDtos.cs new file mode 100644 index 0000000..59c701a --- /dev/null +++ b/backend/Application/Dtos/ApplicationDtos.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + +public sealed record GrantCallResponse( + string Id, + string Name, + DateTimeOffset OpensAt, + DateTimeOffset ClosesAt, + string Status, + string? ExternalId); + +public sealed record CreateApplicationRequest( + [Required] string GrantCallId, + [Required] string IdeaId, + IReadOnlyDictionary? Content); + +public sealed record UpdateApplicationRequest( + IReadOnlyDictionary? Content, + string? Status); + +public sealed record ApplicationResponse( + string Id, + string GrantCallId, + string IdeaId, + IReadOnlyDictionary Content, + string Status, + DateTimeOffset CreatedAt); diff --git a/backend/Application/Dtos/IdeaDtos.cs b/backend/Application/Dtos/IdeaDtos.cs new file mode 100644 index 0000000..81601f7 --- /dev/null +++ b/backend/Application/Dtos/IdeaDtos.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + +public sealed record IdeaCardRequest( + string? Essence, + string? Audience, + string? Stage); + +public sealed record CreateIdeaRequest( + [Required, StringLength(200, MinimumLength = 3)] string Title, + [Required, StringLength(4000, MinimumLength = 10)] string Description, + IdeaCardRequest? Card); + +public sealed record UpdateIdeaRequest( + [Required, StringLength(200, MinimumLength = 3)] string Title, + [Required, StringLength(4000, MinimumLength = 10)] string Description, + string? Status); + +public sealed record IdeaCardResponse( + string Id, + string IdeaId, + string Essence, + string Audience, + string Stage); + +public sealed record IdeaResponse( + string Id, + string Title, + string Description, + string AuthorId, + string Status, + IdeaCardResponse? Card, + DateTimeOffset? UpdatedAt); + +public sealed record IdeaAssistRequest(string? Focus); + +public sealed record IdeaAssistResponse( + IReadOnlyList Suggestions, + string ImprovedDescription, + bool FromLlm); diff --git a/backend/Application/Mappings/IdeaStatusWire.cs b/backend/Application/Mappings/IdeaStatusWire.cs new file mode 100644 index 0000000..a97c6af --- /dev/null +++ b/backend/Application/Mappings/IdeaStatusWire.cs @@ -0,0 +1,26 @@ +using TarninaHub.Domain.Enums; + +namespace TarninaHub.Application.Mappings; + +public static class IdeaStatusWire +{ + public static string ToWire(this IdeaStatus status) => status switch + { + IdeaStatus.Draft => "draft", + IdeaStatus.Submitted => "submitted", + IdeaStatus.InReview => "in_review", + IdeaStatus.Accepted => "accepted", + IdeaStatus.Rejected => "rejected", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; + + public static IdeaStatus? Parse(string? value) => value switch + { + "draft" => IdeaStatus.Draft, + "submitted" => IdeaStatus.Submitted, + "in_review" => IdeaStatus.InReview, + "accepted" => IdeaStatus.Accepted, + "rejected" => IdeaStatus.Rejected, + _ => null, + }; +} diff --git a/backend/Controllers/ApplicationsController.cs b/backend/Controllers/ApplicationsController.cs new file mode 100644 index 0000000..2bc2d0e --- /dev/null +++ b/backend/Controllers/ApplicationsController.cs @@ -0,0 +1,104 @@ +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/applications")] +[Authorize] +public class ApplicationsController : ControllerBase +{ + private readonly IApplicationService _applicationService; + private readonly IIdeaService _ideaService; + + public ApplicationsController(IApplicationService applicationService, IIdeaService ideaService) + { + _applicationService = applicationService; + _ideaService = ideaService; + } + + [HttpPost] + public async Task> Create( + CreateApplicationRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var result = await _applicationService.CreateAsync(userId, request, cancellationToken); + + return result.Outcome switch + { + CreateApplicationOutcome.Created when result.Application is not null => + CreatedAtAction(nameof(GetById), new { applicationId = result.Application.Id }, result.Application), + CreateApplicationOutcome.CallNotFound => NotFound(new ProblemDetails + { + Title = "Grant call not found", + Status = StatusCodes.Status404NotFound, + }), + CreateApplicationOutcome.IdeaNotFound => NotFound(new ProblemDetails + { + Title = "Idea not found", + Status = StatusCodes.Status404NotFound, + }), + _ => Conflict(new ProblemDetails + { + Title = "Grant call is not open", + Detail = "Applications are only accepted while the call is open and within its window.", + Status = StatusCodes.Status409Conflict, + }), + }; + } + + [HttpGet] + public async Task>> ListMine(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var applications = await _applicationService.ListForAuthorAsync(userId, cancellationToken); + + return Ok(applications); + } + + [HttpGet("{applicationId}")] + public async Task> GetById( + string applicationId, + CancellationToken cancellationToken) + { + var application = await _applicationService.GetAsync(applicationId, cancellationToken); + + if (application is null) + { + return NotFound(); + } + + if (!IsStaff()) + { + var idea = await _ideaService.GetAsync(application.IdeaId, cancellationToken); + + if (idea is null || !string.Equals(idea.AuthorId, User.GetUserId(), StringComparison.Ordinal)) + { + return Forbid(); + } + } + + return Ok(application); + } + + private bool IsStaff() => + User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin); +} diff --git a/backend/Controllers/GrantCallsController.cs b/backend/Controllers/GrantCallsController.cs new file mode 100644 index 0000000..c3fe992 --- /dev/null +++ b/backend/Controllers/GrantCallsController.cs @@ -0,0 +1,41 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/grant-calls")] +[Authorize] +public class GrantCallsController : ControllerBase +{ + private readonly IGrantCallService _grantCalls; + + public GrantCallsController(IGrantCallService grantCalls) + { + _grantCalls = grantCalls; + } + + [HttpGet] + public async Task>> List( + [FromQuery] string? status, + CancellationToken cancellationToken) + { + var calls = await _grantCalls.ListAsync(status, cancellationToken); + + return Ok(calls); + } + + [HttpGet("{grantCallId}")] + public async Task> GetById( + string grantCallId, + CancellationToken cancellationToken) + { + var call = await _grantCalls.GetAsync(grantCallId, cancellationToken); + + return call is null ? NotFound() : Ok(call); + } +} diff --git a/backend/Controllers/IdeasController.cs b/backend/Controllers/IdeasController.cs new file mode 100644 index 0000000..dc1007d --- /dev/null +++ b/backend/Controllers/IdeasController.cs @@ -0,0 +1,186 @@ +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; +using TarninaHub.Domain.Rules; + +namespace TarninaHub.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/ideas")] +[Authorize] +public class IdeasController : ControllerBase +{ + private readonly IIdeaService _ideaService; + private readonly IIdeaAssistant _ideaAssistant; + + public IdeasController(IIdeaService ideaService, IIdeaAssistant ideaAssistant) + { + _ideaService = ideaService; + _ideaAssistant = ideaAssistant; + } + + [HttpPost] + public async Task> Create( + CreateIdeaRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var created = await _ideaService.CreateAsync(userId, request, cancellationToken); + + return CreatedAtAction(nameof(GetById), new { ideaId = created.Id }, created); + } + + [HttpGet] + public async Task>> ListMine(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var ideas = await _ideaService.ListForAuthorAsync(userId, cancellationToken); + + return Ok(ideas); + } + + [HttpGet("{ideaId}")] + public async Task> GetById(string ideaId, CancellationToken cancellationToken) + { + var idea = await _ideaService.GetAsync(ideaId, cancellationToken); + + if (idea is null) + { + return NotFound(); + } + + if (!IsOwner(idea) && !IsStaff()) + { + return Forbid(); + } + + return Ok(idea); + } + + [HttpPut("{ideaId}")] + public async Task> Update( + string ideaId, + UpdateIdeaRequest request, + CancellationToken cancellationToken) + { + var current = await _ideaService.GetAsync(ideaId, cancellationToken); + + if (current is null) + { + return NotFound(); + } + + if (!IsOwner(current)) + { + return Forbid(); + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + var from = IdeaStatusWire.Parse(current.Status); + var to = IdeaStatusWire.Parse(request.Status); + + if (to is null || from is null || !IdeaWorkflow.CanTransition(from.Value, to.Value)) + { + ModelState.AddModelError( + nameof(request.Status), + $"Transition from '{current.Status}' to '{request.Status}' is not allowed."); + + return ValidationProblem(ModelState); + } + } + + var updated = await _ideaService.UpdateAsync(ideaId, request, cancellationToken); + + return updated is null ? NotFound() : Ok(updated); + } + + [HttpGet("{ideaId}/card")] + public async Task> GetCard(string ideaId, CancellationToken cancellationToken) + { + var idea = await _ideaService.GetAsync(ideaId, cancellationToken); + + if (idea is null) + { + return NotFound(); + } + + if (!IsOwner(idea) && !IsStaff()) + { + return Forbid(); + } + + var card = await _ideaService.GetCardAsync(ideaId, cancellationToken); + + return card is null ? NotFound() : Ok(card); + } + + [HttpPut("{ideaId}/card")] + public async Task> UpdateCard( + string ideaId, + IdeaCardRequest request, + CancellationToken cancellationToken) + { + var idea = await _ideaService.GetAsync(ideaId, cancellationToken); + + if (idea is null) + { + return NotFound(); + } + + if (!IsOwner(idea)) + { + return Forbid(); + } + + var card = await _ideaService.UpdateCardAsync(ideaId, request, cancellationToken); + + return card is null ? NotFound() : Ok(card); + } + + [HttpPost("{ideaId}/assist")] + public async Task> Assist( + string ideaId, + IdeaAssistRequest request, + CancellationToken cancellationToken) + { + var idea = await _ideaService.GetAsync(ideaId, cancellationToken); + + if (idea is null) + { + return NotFound(); + } + + if (!IsOwner(idea) && !IsStaff()) + { + return Forbid(); + } + + var assist = await _ideaAssistant.AssistAsync(ideaId, request, cancellationToken); + + return assist is null ? NotFound() : Ok(assist); + } + + private bool IsOwner(IdeaResponse idea) => + string.Equals(idea.AuthorId, User.GetUserId(), StringComparison.Ordinal); + + private bool IsStaff() => + User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin); +} diff --git a/backend/Domain/Rules/ApplicationWindow.cs b/backend/Domain/Rules/ApplicationWindow.cs new file mode 100644 index 0000000..eecc074 --- /dev/null +++ b/backend/Domain/Rules/ApplicationWindow.cs @@ -0,0 +1,13 @@ +namespace TarninaHub.Domain.Rules; + +public static class ApplicationWindow +{ + public const string Open = "open"; + + /// + /// Applications are only allowed while the call is open and the current + /// moment is within [opensAt, closesAt]. + /// + public static bool IsOpen(string status, DateTimeOffset opensAt, DateTimeOffset closesAt, DateTimeOffset now) => + string.Equals(status, Open, StringComparison.Ordinal) && now >= opensAt && now <= closesAt; +} diff --git a/backend/Domain/Rules/IdeaWorkflow.cs b/backend/Domain/Rules/IdeaWorkflow.cs new file mode 100644 index 0000000..d8fb19c --- /dev/null +++ b/backend/Domain/Rules/IdeaWorkflow.cs @@ -0,0 +1,22 @@ +using TarninaHub.Domain.Enums; + +namespace TarninaHub.Domain.Rules; + +public static class IdeaWorkflow +{ + private static readonly IReadOnlyDictionary Transitions = + new Dictionary + { + [IdeaStatus.Draft] = [IdeaStatus.Submitted], + [IdeaStatus.Submitted] = [IdeaStatus.InReview], + [IdeaStatus.InReview] = [IdeaStatus.Accepted, IdeaStatus.Rejected], + [IdeaStatus.Accepted] = [], + [IdeaStatus.Rejected] = [], + }; + + public static bool CanTransition(IdeaStatus from, IdeaStatus to) => + from == to || Transitions[from].Contains(to); + + public static bool IsTerminal(IdeaStatus status) => + Transitions[status].Length == 0; +} diff --git a/backend/Infrastructure/Assist/IdeaAssistant.cs b/backend/Infrastructure/Assist/IdeaAssistant.cs new file mode 100644 index 0000000..fd748a4 --- /dev/null +++ b/backend/Infrastructure/Assist/IdeaAssistant.cs @@ -0,0 +1,87 @@ +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Matchmaking; + +namespace TarninaHub.Infrastructure.Assist; + +/// +/// Idea-creator assistant. Uses the configured LLM when available and falls +/// back to deterministic, stage-aware suggestions otherwise. +/// +public sealed class IdeaAssistant : IIdeaAssistant +{ + private const string SystemPrompt = + "Jesteś asystentem kreatora innowacji społecznych w Małopolskim Hubie Innowacji Społecznych. " + + "Rozwijasz pomysł mieszkańca: podajesz konkretne, praktyczne podpowiedzi prostym językiem. " + + "Nie wymyślaj faktów ani danych liczbowych."; + + private readonly IIdeaService _ideaService; + private readonly ILlmProvider _llmProvider; + private readonly ILlmRequestLimiter _llmRequestLimiter; + + public IdeaAssistant( + IIdeaService ideaService, + ILlmProvider llmProvider, + ILlmRequestLimiter llmRequestLimiter) + { + _ideaService = ideaService; + _llmProvider = llmProvider; + _llmRequestLimiter = llmRequestLimiter; + } + + public async Task AssistAsync( + string ideaId, + IdeaAssistRequest request, + CancellationToken cancellationToken) + { + var idea = await _ideaService.GetAsync(ideaId, cancellationToken); + + if (idea is null) + { + return null; + } + + var improvedDescription = idea.Description; + var fromLlm = false; + + if (_llmProvider.IsConfigured && _llmRequestLimiter.TryAcquire()) + { + var userPrompt = + $"Pomysł: {idea.Title}\n" + + $"Opis: {idea.Description}\n" + + $"Etap: {idea.Card?.Stage ?? "idea"}\n" + + $"Odbiorca: {idea.Card?.Audience ?? "nieokreślony"}\n" + + $"Fokus: {request.Focus ?? "ogólny"}\n" + + "Rozwiń opis pomysłu i dodaj praktyczne wskazówki."; + + var completion = await _llmProvider.CompleteAsync(SystemPrompt, userPrompt, cancellationToken); + + if (!string.IsNullOrWhiteSpace(completion)) + { + improvedDescription = completion.Trim(); + fromLlm = true; + } + } + + return new IdeaAssistResponse(BuildSuggestions(idea), improvedDescription, fromLlm); + } + + private static IReadOnlyList BuildSuggestions(IdeaResponse idea) + { + var stage = idea.Card?.Stage ?? "idea"; + var suggestions = new List + { + "Doprecyzuj grupę docelową i jej konkretną potrzebę.", + "Określ, jak zmierzysz efekt (wskaźniki, liczba odbiorców).", + "Zaplanuj mały pilotaż i wskaż partnerów (NGO/JST/firma).", + $"Dopasuj opis do etapu '{stage}' i sprawdź wymagania naboru.", + }; + + if (!string.IsNullOrWhiteSpace(idea.Card?.Audience)) + { + suggestions.Add($"Rozwiń opis odbiorcy: {idea.Card.Audience}."); + } + + return suggestions; + } +} diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index 5f6924b..fbb736c 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -4,6 +4,7 @@ using SurrealDb.Net; using TarninaHub.Application.Abstractions; using TarninaHub.Application.Matchmaking; using TarninaHub.Infrastructure.Auth; +using TarninaHub.Infrastructure.Assist; using TarninaHub.Infrastructure.Events; using TarninaHub.Infrastructure.Llm; using TarninaHub.Infrastructure.Persistence; @@ -80,6 +81,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/ApplicationService.cs b/backend/Infrastructure/Persistence/ApplicationService.cs new file mode 100644 index 0000000..de808ae --- /dev/null +++ b/backend/Infrastructure/Persistence/ApplicationService.cs @@ -0,0 +1,136 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Domain.Rules; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class ApplicationService : IApplicationService +{ + private const string SelectFields = + "type::string(id) AS id, type::string(grant_call) AS grant_call, type::string(idea) AS idea, " + + "content, status, created_at"; + + private readonly ISurrealDbClient _client; + private readonly IGrantCallService _grantCallService; + private readonly IIdeaService _ideaService; + + public ApplicationService( + ISurrealDbClient client, + IGrantCallService grantCallService, + IIdeaService ideaService) + { + _client = client; + _grantCallService = grantCallService; + _ideaService = ideaService; + } + + public async Task CreateAsync( + string userId, + CreateApplicationRequest request, + CancellationToken cancellationToken) + { + var grantCall = await _grantCallService.GetAsync(request.GrantCallId, cancellationToken); + + if (grantCall is null) + { + return new CreateApplicationResult(CreateApplicationOutcome.CallNotFound, null); + } + + if (!ApplicationWindow.IsOpen(grantCall.Status, grantCall.OpensAt, grantCall.ClosesAt, DateTimeOffset.UtcNow)) + { + return new CreateApplicationResult(CreateApplicationOutcome.CallNotOpen, null); + } + + var idea = await _ideaService.GetAsync(request.IdeaId, cancellationToken); + + if (idea is null) + { + return new CreateApplicationResult(CreateApplicationOutcome.IdeaNotFound, null); + } + + var id = Guid.NewGuid().ToString("N"); + var (grantTable, grantId) = SurrealRecordId.Split(request.GrantCallId); + var (ideaTable, ideaIdPart) = SurrealRecordId.Split(request.IdeaId); + + var response = await _client.RawQuery( + "CREATE type::record('application', $id) SET " + + "grant_call = type::record($grantTable, $grantId), " + + "idea = type::record($ideaTable, $ideaId), " + + "content = $content, status = 'draft'; " + + $"SELECT {SelectFields} FROM type::record('application', $id) LIMIT 1;", + new Dictionary + { + ["id"] = id, + ["grantTable"] = grantTable, + ["grantId"] = grantId, + ["ideaTable"] = ideaTable, + ["ideaId"] = ideaIdPart, + ["content"] = ToStorageContent(request.Content), + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return new CreateApplicationResult(CreateApplicationOutcome.Created, Map(rows[0])); + } + + public async Task GetAsync(string applicationId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(applicationId); + + 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> ListForAuthorAsync( + string authorId, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(authorId); + + var response = await _client.RawQuery( + $"SELECT {SelectFields} FROM application WHERE idea.author = type::record($table, $id) " + + "ORDER BY created_at DESC;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Select(Map).ToList(); + } + + private static ApplicationResponse Map(ApplicationRow row) => new( + row.Id, + row.GrantCall, + row.Idea, + row.Content.ToDictionary(pair => pair.Key, pair => (object?)pair.Value), + row.Status, + row.CreatedAt); + + private static Dictionary ToStorageContent(IReadOnlyDictionary? content) + { + var result = new Dictionary(); + + if (content is null) + { + return result; + } + + foreach (var (key, value) in content) + { + result[key] = value?.ToString(); + } + + return result; + } +} diff --git a/backend/Infrastructure/Persistence/GrantCallService.cs b/backend/Infrastructure/Persistence/GrantCallService.cs new file mode 100644 index 0000000..3d85f8e --- /dev/null +++ b/backend/Infrastructure/Persistence/GrantCallService.cs @@ -0,0 +1,65 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class GrantCallService : IGrantCallService +{ + private const string SelectFields = + "type::string(id) AS id, name, opens_at, closes_at, status, external_id"; + + private readonly ISurrealDbClient _client; + + public GrantCallService(ISurrealDbClient client) + { + _client = client; + } + + public async Task> ListAsync( + string? status, + CancellationToken cancellationToken) + { + var parameters = new Dictionary(); + var where = string.Empty; + + if (!string.IsNullOrWhiteSpace(status)) + { + where = " WHERE status = $status"; + parameters["status"] = status; + } + + var response = await _client.RawQuery( + $"SELECT {SelectFields} FROM grant_call{where} ORDER BY opens_at DESC;", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Select(Map).ToList(); + } + + public async Task GetAsync(string grantCallId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(grantCallId); + + 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; + } + + private static GrantCallResponse Map(GrantCallRow row) => new( + row.Id, + row.Name, + row.OpensAt, + row.ClosesAt, + row.Status, + row.ExternalId); +} diff --git a/backend/Infrastructure/Persistence/IdeaService.cs b/backend/Infrastructure/Persistence/IdeaService.cs new file mode 100644 index 0000000..2f7ce5e --- /dev/null +++ b/backend/Infrastructure/Persistence/IdeaService.cs @@ -0,0 +1,218 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Mappings; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class IdeaService : IIdeaService +{ + private const string SelectIdeaFields = + "type::string(id) AS id, title, description, type::string(author) AS author, status, updated_at"; + + private const string SelectCardFields = + "type::string(id) AS id, type::string(idea) AS idea, essence, audience, stage"; + + private readonly ISurrealDbClient _client; + + public IdeaService(ISurrealDbClient client) + { + _client = client; + } + + public async Task CreateAsync( + string authorId, + CreateIdeaRequest request, + CancellationToken cancellationToken) + { + var ideaId = Guid.NewGuid().ToString("N"); + var cardId = Guid.NewGuid().ToString("N"); + var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId); + var card = request.Card ?? new IdeaCardRequest(null, null, null); + + var ideaResponse = await _client.RawQuery( + "CREATE type::record('idea', $id) SET title = $title, description = $description, " + + "author = type::record($authorTable, $authorId), status = 'draft';", + new Dictionary + { + ["id"] = ideaId, + ["title"] = request.Title, + ["description"] = request.Description, + ["authorTable"] = authorTable, + ["authorId"] = authorIdPart, + }, + cancellationToken); + ideaResponse.EnsureAllOks(); + + var cardResponse = await _client.RawQuery( + "CREATE type::record('idea_card', $id) SET idea = type::record('idea', $ideaId), " + + "essence = $essence, audience = $audience, stage = $stage;", + new Dictionary + { + ["id"] = cardId, + ["ideaId"] = ideaId, + ["essence"] = card.Essence ?? string.Empty, + ["audience"] = card.Audience ?? string.Empty, + ["stage"] = string.IsNullOrWhiteSpace(card.Stage) ? "idea" : card.Stage, + }, + cancellationToken); + cardResponse.EnsureAllOks(); + + return await LoadAsync($"idea:{ideaId}", cancellationToken) + ?? throw new InvalidOperationException("Idea could not be loaded after creation."); + } + + public async Task GetAsync(string ideaId, CancellationToken cancellationToken) => + await LoadAsync(ideaId, cancellationToken); + + public async Task> ListForAuthorAsync( + string authorId, + CancellationToken cancellationToken) + { + var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId); + + var response = await _client.RawQuery( + $"SELECT {SelectIdeaFields} FROM idea WHERE author = type::record($authorTable, $authorId) " + + "ORDER BY updated_at DESC;", + new Dictionary + { + ["authorTable"] = authorTable, + ["authorId"] = authorIdPart, + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + var result = new List(rows.Count); + + foreach (var row in rows) + { + result.Add(Map(row, await LoadCardAsync(row.Id, cancellationToken))); + } + + return result; + } + + public async Task UpdateAsync( + string ideaId, + UpdateIdeaRequest request, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(ideaId); + var parameters = new Dictionary + { + ["table"] = table, + ["id"] = id, + ["title"] = request.Title, + ["description"] = request.Description, + }; + + var assignments = new List + { + "title = $title", "description = $description", "updated_at = time::now()", + }; + + if (!string.IsNullOrWhiteSpace(request.Status) && IdeaStatusWire.Parse(request.Status) is { } status) + { + assignments.Add("status = $status"); + parameters["status"] = status.ToWire(); + } + + var response = await _client.RawQuery( + $"UPDATE type::record($table, $id) SET {string.Join(", ", assignments)}; " + + $"SELECT {SelectIdeaFields} FROM type::record($table, $id) LIMIT 1;", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + if (rows.Count == 0) + { + return null; + } + + return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken)); + } + + public async Task GetCardAsync(string ideaId, CancellationToken cancellationToken) => + await LoadCardAsync(ideaId, cancellationToken); + + public async Task UpdateCardAsync( + string ideaId, + IdeaCardRequest request, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(ideaId); + + var response = await _client.RawQuery( + "UPDATE idea_card SET essence = $essence, audience = $audience, stage = $stage " + + "WHERE idea = type::record($table, $id); " + + $"SELECT {SelectCardFields} FROM idea_card WHERE idea = type::record($table, $id) LIMIT 1;", + new Dictionary + { + ["table"] = table, + ["id"] = id, + ["essence"] = request.Essence ?? string.Empty, + ["audience"] = request.Audience ?? string.Empty, + ["stage"] = string.IsNullOrWhiteSpace(request.Stage) ? "idea" : request.Stage, + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 ? MapCard(rows[0]) : null; + } + + private async Task LoadAsync(string ideaId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(ideaId); + + var response = await _client.RawQuery( + $"SELECT {SelectIdeaFields} FROM type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + if (rows.Count == 0) + { + return null; + } + + return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken)); + } + + private async Task LoadCardAsync(string ideaId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(ideaId); + + var response = await _client.RawQuery( + $"SELECT {SelectCardFields} FROM idea_card WHERE idea = type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Count > 0 ? MapCard(rows[0]) : null; + } + + private static IdeaResponse Map(IdeaRow row, IdeaCardResponse? card) => new( + row.Id, + row.Title, + row.Description, + row.Author, + row.Status, + card, + row.UpdatedAt); + + private static IdeaCardResponse MapCard(IdeaCardRow row) => new( + row.Id, + row.Idea, + row.Essence, + row.Audience, + row.Stage); +} diff --git a/backend/Infrastructure/Persistence/SurrealRows.cs b/backend/Infrastructure/Persistence/SurrealRows.cs index a8f975b..cc20393 100644 --- a/backend/Infrastructure/Persistence/SurrealRows.cs +++ b/backend/Infrastructure/Persistence/SurrealRows.cs @@ -155,3 +155,61 @@ internal sealed class IdRow { [CborProperty("id")] public string Id { get; set; } = string.Empty; } + +internal sealed class IdeaRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("title")] public string Title { get; set; } = string.Empty; + + [CborProperty("description")] public string Description { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; + + [CborProperty("status")] public string Status { get; set; } = string.Empty; + + [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } +} + +internal sealed class IdeaCardRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("idea")] public string Idea { get; set; } = string.Empty; + + [CborProperty("essence")] public string Essence { get; set; } = string.Empty; + + [CborProperty("audience")] public string Audience { get; set; } = string.Empty; + + [CborProperty("stage")] public string Stage { get; set; } = string.Empty; +} + +internal sealed class GrantCallRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("name")] public string Name { get; set; } = string.Empty; + + [CborProperty("opens_at")] public DateTimeOffset OpensAt { get; set; } + + [CborProperty("closes_at")] public DateTimeOffset ClosesAt { get; set; } + + [CborProperty("status")] public string Status { get; set; } = string.Empty; + + [CborProperty("external_id")] public string? ExternalId { get; set; } +} + +internal sealed class ApplicationRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("grant_call")] public string GrantCall { get; set; } = string.Empty; + + [CborProperty("idea")] public string Idea { get; set; } = string.Empty; + + [CborProperty("content")] public Dictionary Content { get; set; } = []; + + [CborProperty("status")] public string Status { get; set; } = string.Empty; + + [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } +}