feat(ideas): add idea creator, grant calls and applications
Implement the idea creator and application generator: idea status workflow, social canvas card, grant call windows with 409 when closed, and an LLM-backed idea assistant with offline fallback.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
using TarninaHub.Application.Dtos;
|
||||
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public interface IIdeaService
|
||||
{
|
||||
Task<IdeaResponse> CreateAsync(string authorId, CreateIdeaRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<IdeaResponse?> GetAsync(string ideaId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<IdeaResponse>> ListForAuthorAsync(string authorId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IdeaResponse?> UpdateAsync(string ideaId, UpdateIdeaRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<IdeaCardResponse?> GetCardAsync(string ideaId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IdeaCardResponse?> UpdateCardAsync(string ideaId, IdeaCardRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IIdeaAssistant
|
||||
{
|
||||
Task<IdeaAssistResponse?> AssistAsync(
|
||||
string ideaId,
|
||||
IdeaAssistRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IGrantCallService
|
||||
{
|
||||
Task<IReadOnlyList<GrantCallResponse>> ListAsync(string? status, CancellationToken cancellationToken);
|
||||
|
||||
Task<GrantCallResponse?> GetAsync(string grantCallId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public enum CreateApplicationOutcome
|
||||
{
|
||||
Created,
|
||||
CallNotFound,
|
||||
CallNotOpen,
|
||||
IdeaNotFound,
|
||||
}
|
||||
|
||||
public sealed record CreateApplicationResult(
|
||||
CreateApplicationOutcome Outcome,
|
||||
ApplicationResponse? Application);
|
||||
|
||||
public interface IApplicationService
|
||||
{
|
||||
Task<CreateApplicationResult> CreateAsync(
|
||||
string userId,
|
||||
CreateApplicationRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<ApplicationResponse?> GetAsync(string applicationId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ApplicationResponse>> ListForAuthorAsync(
|
||||
string authorId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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<string, object?>? Content);
|
||||
|
||||
public sealed record UpdateApplicationRequest(
|
||||
IReadOnlyDictionary<string, object?>? Content,
|
||||
string? Status);
|
||||
|
||||
public sealed record ApplicationResponse(
|
||||
string Id,
|
||||
string GrantCallId,
|
||||
string IdeaId,
|
||||
IReadOnlyDictionary<string, object?> Content,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -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<string> Suggestions,
|
||||
string ImprovedDescription,
|
||||
bool FromLlm);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<ActionResult<ApplicationResponse>> 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<ActionResult<IReadOnlyList<ApplicationResponse>>> 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<ActionResult<ApplicationResponse>> 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);
|
||||
}
|
||||
@@ -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<ActionResult<IReadOnlyList<GrantCallResponse>>> List(
|
||||
[FromQuery] string? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var calls = await _grantCalls.ListAsync(status, cancellationToken);
|
||||
|
||||
return Ok(calls);
|
||||
}
|
||||
|
||||
[HttpGet("{grantCallId}")]
|
||||
public async Task<ActionResult<GrantCallResponse>> GetById(
|
||||
string grantCallId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var call = await _grantCalls.GetAsync(grantCallId, cancellationToken);
|
||||
|
||||
return call is null ? NotFound() : Ok(call);
|
||||
}
|
||||
}
|
||||
@@ -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<ActionResult<IdeaResponse>> 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<ActionResult<IReadOnlyList<IdeaResponse>>> 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<ActionResult<IdeaResponse>> 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<ActionResult<IdeaResponse>> 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<ActionResult<IdeaCardResponse>> 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<ActionResult<IdeaCardResponse>> 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<ActionResult<IdeaAssistResponse>> 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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TarninaHub.Domain.Rules;
|
||||
|
||||
public static class ApplicationWindow
|
||||
{
|
||||
public const string Open = "open";
|
||||
|
||||
/// <summary>
|
||||
/// Applications are only allowed while the call is open and the current
|
||||
/// moment is within [opensAt, closesAt].
|
||||
/// </summary>
|
||||
public static bool IsOpen(string status, DateTimeOffset opensAt, DateTimeOffset closesAt, DateTimeOffset now) =>
|
||||
string.Equals(status, Open, StringComparison.Ordinal) && now >= opensAt && now <= closesAt;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TarninaHub.Domain.Enums;
|
||||
|
||||
namespace TarninaHub.Domain.Rules;
|
||||
|
||||
public static class IdeaWorkflow
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<IdeaStatus, IdeaStatus[]> Transitions =
|
||||
new Dictionary<IdeaStatus, IdeaStatus[]>
|
||||
{
|
||||
[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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Matchmaking;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Assist;
|
||||
|
||||
/// <summary>
|
||||
/// Idea-creator assistant. Uses the configured LLM when available and falls
|
||||
/// back to deterministic, stage-aware suggestions otherwise.
|
||||
/// </summary>
|
||||
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<IdeaAssistResponse?> 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<string> BuildSuggestions(IdeaResponse idea)
|
||||
{
|
||||
var stage = idea.Card?.Stage ?? "idea";
|
||||
var suggestions = new List<string>
|
||||
{
|
||||
"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;
|
||||
}
|
||||
}
|
||||
@@ -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<ITrendService, TrendService>();
|
||||
services.AddSingleton<IEmbeddingProvider, HashingEmbeddingProvider>();
|
||||
services.AddSingleton<IMatchmakingService, MatchmakingService>();
|
||||
services.AddSingleton<IIdeaService, IdeaService>();
|
||||
services.AddSingleton<IGrantCallService, GrantCallService>();
|
||||
services.AddSingleton<IApplicationService, ApplicationService>();
|
||||
services.AddSingleton<IIdeaAssistant, IdeaAssistant>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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<CreateApplicationResult> 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<string, object?>
|
||||
{
|
||||
["id"] = id,
|
||||
["grantTable"] = grantTable,
|
||||
["grantId"] = grantId,
|
||||
["ideaTable"] = ideaTable,
|
||||
["ideaId"] = ideaIdPart,
|
||||
["content"] = ToStorageContent(request.Content),
|
||||
},
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ApplicationRow>>(1) ?? [];
|
||||
|
||||
return new CreateApplicationResult(CreateApplicationOutcome.Created, Map(rows[0]));
|
||||
}
|
||||
|
||||
public async Task<ApplicationResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ApplicationRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApplicationResponse>> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ApplicationRow>>(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<string, string?> ToStorageContent(IReadOnlyDictionary<string, object?>? content)
|
||||
{
|
||||
var result = new Dictionary<string, string?>();
|
||||
|
||||
if (content is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in content)
|
||||
{
|
||||
result[key] = value?.ToString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<GrantCallResponse>> ListAsync(
|
||||
string? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = new Dictionary<string, object?>();
|
||||
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<List<GrantCallRow>>(0) ?? [];
|
||||
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async Task<GrantCallResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<GrantCallRow>>(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);
|
||||
}
|
||||
@@ -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<IdeaResponse> 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<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["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<IdeaResponse?> GetAsync(string ideaId, CancellationToken cancellationToken) =>
|
||||
await LoadAsync(ideaId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<IdeaResponse>> 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<string, object?>
|
||||
{
|
||||
["authorTable"] = authorTable,
|
||||
["authorId"] = authorIdPart,
|
||||
},
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<IdeaRow>>(0) ?? [];
|
||||
var result = new List<IdeaResponse>(rows.Count);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
result.Add(Map(row, await LoadCardAsync(row.Id, cancellationToken)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IdeaResponse?> UpdateAsync(
|
||||
string ideaId,
|
||||
UpdateIdeaRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (table, id) = SurrealRecordId.Split(ideaId);
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["table"] = table,
|
||||
["id"] = id,
|
||||
["title"] = request.Title,
|
||||
["description"] = request.Description,
|
||||
};
|
||||
|
||||
var assignments = new List<string>
|
||||
{
|
||||
"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<List<IdeaRow>>(1) ?? [];
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<IdeaCardResponse?> GetCardAsync(string ideaId, CancellationToken cancellationToken) =>
|
||||
await LoadCardAsync(ideaId, cancellationToken);
|
||||
|
||||
public async Task<IdeaCardResponse?> 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<string, object?>
|
||||
{
|
||||
["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<List<IdeaCardRow>>(1) ?? [];
|
||||
|
||||
return rows.Count > 0 ? MapCard(rows[0]) : null;
|
||||
}
|
||||
|
||||
private async Task<IdeaResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<IdeaRow>>(0) ?? [];
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<IdeaCardResponse?> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<IdeaCardRow>>(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);
|
||||
}
|
||||
@@ -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<string, string?> Content { get; set; } = [];
|
||||
|
||||
[CborProperty("status")] public string Status { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user