feat(backend): add communication, tester, admin and middleman modules

Implement step 17 modules: threads/messages, notifications with automation (submission.created, idea.created, grant_call.opened), innovation test feedback, admin panel (submissions, verify, metrics, grant call status), Middleman adaptation and partnerships. Split multi-type files into one type per file and replace object-valued dictionaries with string-valued maps in data contracts (migrations 0006/0007).
This commit is contained in:
Rafał Tyński
2026-10-04 03:49:45 +02:00
parent f4f3b3896a
commit fa1199539b
136 changed files with 2338 additions and 574 deletions
@@ -0,0 +1,18 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IAdminService
{
Task<IReadOnlyList<SubmissionResponse>> ListSubmissionsAsync(
string? status,
string? challengeId,
CancellationToken cancellationToken);
Task<KnowledgeItemResponse?> VerifyKnowledgeItemAsync(
string knowledgeItemId,
VerifyKnowledgeItemRequest request,
CancellationToken cancellationToken);
Task<AdminMetricsResponse> GetMetricsAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,12 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public enum CreateApplicationOutcome
{
Created,
CallNotFound,
CallNotOpen,
IdeaNotFound,
}
@@ -0,0 +1,8 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public sealed record CreateApplicationResult(
CreateApplicationOutcome Outcome,
ApplicationResponse? Application);
@@ -0,0 +1,18 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
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);
}
@@ -6,6 +6,6 @@ public interface IAuditLogService
string? actorId, string? actorId,
string action, string action,
string target, string target,
IReadOnlyDictionary<string, object?> context, IReadOnlyDictionary<string, string?> context,
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }
@@ -0,0 +1,16 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IGrantCallService
{
Task<IReadOnlyList<GrantCallResponse>> ListAsync(string? status, CancellationToken cancellationToken);
Task<GrantCallResponse?> GetAsync(string grantCallId, CancellationToken cancellationToken);
Task<GrantCallResponse?> UpdateStatusAsync(
string grantCallId,
string status,
CancellationToken cancellationToken);
}
@@ -0,0 +1,12 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IIdeaAssistant
{
Task<IdeaAssistResponse?> AssistAsync(
string ideaId,
IdeaAssistRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,21 @@
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<IReadOnlyList<string>> ListDistinctAuthorIdsAsync(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);
}
@@ -0,0 +1,27 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface INotificationService
{
Task NotifyStaffAsync(
string type,
IReadOnlyDictionary<string, string?> payload,
CancellationToken cancellationToken);
Task NotifyUserAsync(
string userId,
string type,
IReadOnlyDictionary<string, string?> payload,
CancellationToken cancellationToken);
Task<IReadOnlyList<NotificationResponse>> ListForUserAsync(
string userId,
CancellationToken cancellationToken);
Task<NotificationResponse?> MarkReadAsync(
string notificationId,
string userId,
CancellationToken cancellationToken);
}
@@ -1,11 +1,5 @@
namespace TarninaHub.Application.Abstractions; namespace TarninaHub.Application.Abstractions;
public sealed record SubmissionEvent(
string Type,
string SubmissionId,
string? ActorId,
string Status,
DateTimeOffset At);
public interface ISubmissionEventPublisher public interface ISubmissionEventPublisher
{ {
@@ -0,0 +1,30 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IThreadService
{
Task<IReadOnlyList<ThreadResponse>> ListForUserAsync(
string userId,
bool isStaff,
CancellationToken cancellationToken);
Task<ThreadResponse?> GetAsync(
string threadId,
string userId,
bool isStaff,
CancellationToken cancellationToken);
Task<ThreadResponse> CreateAsync(
string creatorId,
CreateThreadRequest request,
CancellationToken cancellationToken);
Task<MessageResponse?> AddMessageAsync(
string threadId,
string authorId,
bool isStaff,
CreateMessageRequest request,
CancellationToken cancellationToken);
}
@@ -2,9 +2,8 @@ using TarninaHub.Application.Models;
namespace TarninaHub.Application.Abstractions; namespace TarninaHub.Application.Abstractions;
public interface ITokenService public interface ITokenService
{ {
TokenResult CreateToken(UserAccount user); TokenResult CreateToken(UserAccount user);
} }
public sealed record TokenResult(string AccessToken, DateTimeOffset ExpiresAt);
@@ -1,59 +0,0 @@
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,8 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IMiddlemanService
{
Task<MiddlemanResponse?> AdaptAsync(MiddlemanRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,16 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IPartnershipService
{
Task<IReadOnlyList<PartnershipResponse>> ListForUserAsync(
string userId,
bool isStaff,
CancellationToken cancellationToken);
Task<PartnershipResponse> CreateAsync(
string initiatorId,
CreatePartnershipRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
namespace TarninaHub.Application.Abstractions;
public sealed record SubmissionEvent(
string Type,
string SubmissionId,
string? ActorId,
string Status,
DateTimeOffset At);
@@ -0,0 +1,16 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface ITestFeedbackService
{
Task<TestFeedbackResponse?> CreateAsync(
string innovationId,
string authorId,
CreateTestFeedbackRequest request,
CancellationToken cancellationToken);
Task<IReadOnlyList<TestFeedbackResponse>> ListForInnovationAsync(
string innovationId,
CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
using TarninaHub.Application.Models;
namespace TarninaHub.Application.Abstractions;
public sealed record TokenResult(string AccessToken, DateTimeOffset ExpiresAt);
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record AdminMetricsResponse(
int Submissions,
int Problems,
int Ideas,
int Innovations,
int KnowledgeItems,
int Users,
int OpenGrantCalls,
int Applications);
@@ -1,28 +0,0 @@
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,12 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record ApplicationResponse(
string Id,
string GrantCallId,
string IdeaId,
IReadOnlyDictionary<string, string?> Content,
string Status,
DateTimeOffset CreatedAt);
-18
View File
@@ -1,18 +0,0 @@
namespace TarninaHub.Application.Dtos;
public sealed record RegisterRequest(string Phone, string? Email, string Password);
public sealed record LoginRequest(string Phone, string Password);
public sealed record UserResponse(
string Id,
string Phone,
string? Email,
string Role,
string? OrganizationId);
public sealed record TokenResponse(
string AccessToken,
string TokenType,
DateTimeOffset ExpiresAt,
UserResponse User);
@@ -1,6 +1,5 @@
namespace TarninaHub.Application.Dtos; namespace TarninaHub.Application.Dtos;
public sealed record GrantConsentRequest(string Scope, DateTimeOffset? ExpiresAt);
public sealed record ConsentResponse( public sealed record ConsentResponse(
string Id, string Id,
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record CreateApplicationRequest(
[Required] string GrantCallId,
[Required] string IdeaId,
IReadOnlyDictionary<string, string?>? Content);
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record CreateIdeaRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[Required, StringLength(4000, MinimumLength = 10)] string Description,
IdeaCardRequest? Card);
@@ -0,0 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record CreateMessageRequest(
[Required, StringLength(4000, MinimumLength = 1)] string Body);
@@ -0,0 +1,8 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record CreatePartnershipRequest(
[Required, StringLength(200, MinimumLength = 3)] string Subject,
IReadOnlyList<string>? Partners);
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record CreateSubmissionRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[Required, StringLength(4000, MinimumLength = 10)] string Description,
[Required] string ChallengeId,
[Required] string LocationId,
IReadOnlyList<string>? Keywords);
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record CreateTestFeedbackRequest(
[Range(1, 5)] int Rating,
string? Comment,
string? Improvements);
@@ -0,0 +1,8 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record CreateThreadRequest(
[Required, StringLength(200, MinimumLength = 3)] string Subject,
IReadOnlyList<string>? Participants);
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record GrantCallResponse(
string Id,
string Name,
DateTimeOffset OpensAt,
DateTimeOffset ClosesAt,
string Status,
string? ExternalId);
@@ -0,0 +1,4 @@
namespace TarninaHub.Application.Dtos;
public sealed record GrantConsentRequest(string Scope, DateTimeOffset? ExpiresAt);
@@ -0,0 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record IdeaAssistRequest(string? Focus);
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record IdeaAssistResponse(
IReadOnlyList<string> Suggestions,
string ImprovedDescription,
bool FromLlm);
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record IdeaCardRequest(
string? Essence,
string? Audience,
string? Stage);
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record IdeaCardResponse(
string Id,
string IdeaId,
string Essence,
string Audience,
string Stage);
-41
View File
@@ -1,41 +0,0 @@
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);
+13
View File
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record IdeaResponse(
string Id,
string Title,
string Description,
string AuthorId,
string Status,
IdeaCardResponse? Card,
DateTimeOffset? UpdatedAt);
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record InnovationRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[StringLength(2000)] string? Summary,
[StringLength(8000)] string? Description,
[Required] string ChallengeId,
[Required] string Stage,
string? Cost,
IReadOnlyDictionary<string, object?>? Contact,
IReadOnlyList<IReadOnlyDictionary<string, object?>>? Media,
bool Published);
public sealed record InnovationResponse(
string Id,
string Title,
string Summary,
string Description,
string ChallengeId,
string Stage,
string? Cost,
IReadOnlyDictionary<string, object?>? Contact,
IReadOnlyList<IReadOnlyDictionary<string, object?>> Media,
bool Published,
DateTimeOffset? UpdatedAt);
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record InnovationRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[StringLength(2000)] string? Summary,
[StringLength(8000)] string? Description,
[Required] string ChallengeId,
[Required] string Stage,
string? Cost,
IReadOnlyDictionary<string, string?>? Contact,
IReadOnlyList<IReadOnlyDictionary<string, string?>>? Media,
bool Published);
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record InnovationResponse(
string Id,
string Title,
string Summary,
string Description,
string ChallengeId,
string Stage,
string? Cost,
IReadOnlyDictionary<string, string?>? Contact,
IReadOnlyList<IReadOnlyDictionary<string, string?>> Media,
bool Published,
DateTimeOffset? UpdatedAt);
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record InstitutionProfile(
string? Type,
string? Area,
IReadOnlyList<string>? Audiences);
-27
View File
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record KnowledgeItemRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[Required] string Kind,
string? ChallengeId,
string? SourceUrl,
IReadOnlyList<IReadOnlyDictionary<string, object?>>? Attachments,
bool Published);
public sealed record KnowledgeItemResponse(
string Id,
string Title,
string Kind,
string? ChallengeId,
string? SourceUrl,
IReadOnlyList<IReadOnlyDictionary<string, object?>> Attachments,
bool Published,
DateTimeOffset? UpdatedAt);
public sealed record SocialChallengeResponse(
string Id,
string Name,
string Description,
int Order);
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record KnowledgeItemRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[Required] string Kind,
string? ChallengeId,
string? SourceUrl,
IReadOnlyList<IReadOnlyDictionary<string, string?>>? Attachments,
bool Published);
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record KnowledgeItemResponse(
string Id,
string Title,
string Kind,
string? ChallengeId,
string? SourceUrl,
IReadOnlyList<IReadOnlyDictionary<string, string?>> Attachments,
bool Published,
DateTimeOffset? UpdatedAt);
+4
View File
@@ -0,0 +1,4 @@
namespace TarninaHub.Application.Dtos;
public sealed record LoginRequest(string Phone, string Password);
+6
View File
@@ -0,0 +1,6 @@
namespace TarninaHub.Application.Dtos;
public sealed record MatchRequest(
int? MaxResults,
string? ChallengeId);
@@ -0,0 +1,7 @@
namespace TarninaHub.Application.Dtos;
public sealed record MatchResponse(
IReadOnlyList<SimilarCaseItem> SimilarCases,
IReadOnlyList<SolutionItem> Solutions,
bool Fallback);
@@ -1,23 +0,0 @@
namespace TarninaHub.Application.Dtos;
public sealed record MatchRequest(
int? MaxResults,
string? ChallengeId);
public sealed record SimilarCaseItem(
string Id,
string Title,
double Score);
public sealed record SolutionItem(
string InnovationId,
string Title,
string Stage,
string ChallengeId,
double Score,
string Rationale);
public sealed record MatchResponse(
IReadOnlyList<SimilarCaseItem> SimilarCases,
IReadOnlyList<SolutionItem> Solutions,
bool Fallback);
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record MessageResponse(
string Id,
string ThreadId,
string AuthorId,
string Body,
DateTimeOffset CreatedAt);
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record MiddlemanRequest(
[Required] string InnovationId,
InstitutionProfile? InstitutionProfile,
string? Needs);
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record MiddlemanResponse(
string ServiceForm,
IReadOnlyList<string> RequiredResources,
string Rationale,
bool FromLlm);
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record NotificationResponse(
string Id,
string Type,
IReadOnlyDictionary<string, string?> Payload,
DateTimeOffset? ReadAt,
DateTimeOffset CreatedAt);
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record PartnershipResponse(
string Id,
string InitiatorId,
string Subject,
IReadOnlyList<string> Partners,
string Status);
@@ -0,0 +1,4 @@
namespace TarninaHub.Application.Dtos;
public sealed record RegisterRequest(string Phone, string? Email, string Password);
@@ -0,0 +1,7 @@
namespace TarninaHub.Application.Dtos;
public sealed record SimilarCaseItem(
string Id,
string Title,
double Score);
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record SocialChallengeResponse(
string Id,
string Name,
string Description,
int Order);
+10
View File
@@ -0,0 +1,10 @@
namespace TarninaHub.Application.Dtos;
public sealed record SolutionItem(
string InnovationId,
string Title,
string Stage,
string ChallengeId,
double Score,
string Rationale);
+8
View File
@@ -0,0 +1,8 @@
namespace TarninaHub.Application.Dtos;
public sealed record StorageInfo(
string Engine,
string DataDirectory,
string Namespace,
string Database);
-25
View File
@@ -1,25 +0,0 @@
namespace TarninaHub.Application.Dtos;
public sealed class StorageProbe
{
public string Label { get; set; } = "probe";
public DateTimeOffset CreatedAt { get; set; }
}
public sealed record StorageInfo(
string Engine,
string DataDirectory,
string Namespace,
string Database);
public sealed record StorageStatusResponse(
string Engine,
string DataDirectory,
string Namespace,
string Database,
int ProbeCount);
public sealed record StorageProbeResponse(
string Label,
DateTimeOffset CreatedAt);
+9
View File
@@ -0,0 +1,9 @@
namespace TarninaHub.Application.Dtos;
public sealed class StorageProbe
{
public string Label { get; set; } = "probe";
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,6 @@
namespace TarninaHub.Application.Dtos;
public sealed record StorageProbeResponse(
string Label,
DateTimeOffset CreatedAt);
@@ -0,0 +1,9 @@
namespace TarninaHub.Application.Dtos;
public sealed record StorageStatusResponse(
string Engine,
string DataDirectory,
string Namespace,
string Database,
int ProbeCount);
@@ -2,15 +2,6 @@ using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos; namespace TarninaHub.Application.Dtos;
public sealed record CreateSubmissionRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[Required, StringLength(4000, MinimumLength = 10)] string Description,
[Required] string ChallengeId,
[Required] string LocationId,
IReadOnlyList<string>? Keywords);
public sealed record UpdateSubmissionStatusRequest(
[Required] string Status);
public sealed record SubmissionResponse( public sealed record SubmissionResponse(
string Id, string Id,
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record TestFeedbackResponse(
string Id,
string InnovationId,
string AuthorId,
int Rating,
string Comment,
string Improvements,
DateTimeOffset CreatedAt);
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record ThreadResponse(
string Id,
string Subject,
IReadOnlyList<string> Participants,
DateTimeOffset CreatedAt,
IReadOnlyList<MessageResponse> Messages);
@@ -0,0 +1,8 @@
namespace TarninaHub.Application.Dtos;
public sealed record TokenResponse(
string AccessToken,
string TokenType,
DateTimeOffset ExpiresAt,
UserResponse User);
+7
View File
@@ -0,0 +1,7 @@
namespace TarninaHub.Application.Dtos;
public sealed record TrendPoint(
string ChallengeId,
string ChallengeName,
int SubmissionCount);
@@ -1,9 +1,5 @@
namespace TarninaHub.Application.Dtos; namespace TarninaHub.Application.Dtos;
public sealed record TrendPoint(
string ChallengeId,
string ChallengeName,
int SubmissionCount);
public sealed record TrendsResponse( public sealed record TrendsResponse(
DateTimeOffset From, DateTimeOffset From,
@@ -0,0 +1,8 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record UpdateApplicationRequest(
IReadOnlyDictionary<string, string?>? Content,
string? Status);
@@ -0,0 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record UpdateGrantCallStatusRequest([Required] string Status);
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record UpdateIdeaRequest(
[Required, StringLength(200, MinimumLength = 3)] string Title,
[Required, StringLength(4000, MinimumLength = 10)] string Description,
string? Status);
@@ -0,0 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record UpdateSubmissionStatusRequest(
[Required] string Status);
+9
View File
@@ -0,0 +1,9 @@
namespace TarninaHub.Application.Dtos;
public sealed record UserResponse(
string Id,
string Phone,
string? Email,
string Role,
string? OrganizationId);
@@ -0,0 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace TarninaHub.Application.Dtos;
public sealed record VerifyKnowledgeItemRequest(bool Verified);
@@ -0,0 +1,22 @@
using TarninaHub.Domain.Enums;
namespace TarninaHub.Application.Mappings;
public static class PartnershipStatusWire
{
public static string ToWire(this PartnershipStatus status) => status switch
{
PartnershipStatus.Proposed => "proposed",
PartnershipStatus.Active => "active",
PartnershipStatus.Closed => "closed",
_ => "proposed",
};
public static PartnershipStatus? Parse(string? value) => value?.Trim().ToLowerInvariant() switch
{
"proposed" => PartnershipStatus.Proposed,
"active" => PartnershipStatus.Active,
"closed" => PartnershipStatus.Closed,
_ => null,
};
}
@@ -0,0 +1,4 @@
namespace TarninaHub.Application.Matchmaking;
public sealed record LlmPrompt(string System, string User);
@@ -1,6 +1,5 @@
namespace TarninaHub.Application.Matchmaking; namespace TarninaHub.Application.Matchmaking;
public sealed record LlmPrompt(string System, string User);
public static class MatchPromptBuilder public static class MatchPromptBuilder
{ {
@@ -0,0 +1,4 @@
namespace TarninaHub.Application.Matchmaking;
public sealed record RankedItem<T>(T Item, double Score);
@@ -1,6 +1,5 @@
namespace TarninaHub.Application.Matchmaking; namespace TarninaHub.Application.Matchmaking;
public sealed record RankedItem<T>(T Item, double Score);
public static class SemanticRanker public static class SemanticRanker
{ {
@@ -0,0 +1,16 @@
namespace TarninaHub.Application.Security;
public static class ConsentScopes
{
/// <summary>Access granted by the data subject's consent.</summary>
public const string ByConsent = "by-consent";
/// <summary>Time-limited access (uses consent.expires_at).</summary>
public const string Temporary = "temporary";
/// <summary>Fully anonymized data (no personal identifiers).</summary>
public const string Anonymized = "anonymized";
public static readonly IReadOnlyList<string> All = [ByConsent, Temporary, Anonymized];
}
+20
View File
@@ -0,0 +1,20 @@
namespace TarninaHub.Application.Security;
public static class Policies
{
/// <summary>operator, manager, admin — ROPS/expert staff.</summary>
public const string Staff = "Staff";
/// <summary>operator, admin.</summary>
public const string OperatorOrAdmin = "OperatorOrAdmin";
/// <summary>manager, operator, admin.</summary>
public const string ManagerOrAbove = "ManagerOrAbove";
/// <summary>admin only.</summary>
public const string AdminOnly = "AdminOnly";
/// <summary>jst and above (jst, operator, manager, admin).</summary>
public const string JstOrAbove = "JstOrAbove";
}
+16
View File
@@ -0,0 +1,16 @@
namespace TarninaHub.Application.Security;
public static class RoleNames
{
public const string User = "user";
public const string Jst = "jst";
public const string Operator = "operator";
public const string Manager = "manager";
public const string Admin = "admin";
public static readonly IReadOnlyList<string> All =
[User, Jst, Operator, Manager, Admin];
public static bool IsValid(string role) => All.Contains(role);
}
@@ -1,47 +0,0 @@
namespace TarninaHub.Application.Security;
public static class RoleNames
{
public const string User = "user";
public const string Jst = "jst";
public const string Operator = "operator";
public const string Manager = "manager";
public const string Admin = "admin";
public static readonly IReadOnlyList<string> All =
[User, Jst, Operator, Manager, Admin];
public static bool IsValid(string role) => All.Contains(role);
}
public static class Policies
{
/// <summary>operator, manager, admin — ROPS/expert staff.</summary>
public const string Staff = "Staff";
/// <summary>operator, admin.</summary>
public const string OperatorOrAdmin = "OperatorOrAdmin";
/// <summary>manager, operator, admin.</summary>
public const string ManagerOrAbove = "ManagerOrAbove";
/// <summary>admin only.</summary>
public const string AdminOnly = "AdminOnly";
/// <summary>jst and above (jst, operator, manager, admin).</summary>
public const string JstOrAbove = "JstOrAbove";
}
public static class ConsentScopes
{
/// <summary>Access granted by the data subject's consent.</summary>
public const string ByConsent = "by-consent";
/// <summary>Time-limited access (uses consent.expires_at).</summary>
public const string Temporary = "temporary";
/// <summary>Fully anonymized data (no personal identifiers).</summary>
public const string Anonymized = "anonymized";
public static readonly IReadOnlyList<string> All = [ByConsent, Temporary, Anonymized];
}
+98
View File
@@ -0,0 +1,98 @@
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/admin")]
[Authorize(Policy = Policies.OperatorOrAdmin)]
public class AdminController : ControllerBase
{
private readonly IAdminService _admin;
private readonly IGrantCallService _grantCalls;
private readonly IIdeaService _ideas;
private readonly INotificationService _notifications;
public AdminController(
IAdminService admin,
IGrantCallService grantCalls,
IIdeaService ideas,
INotificationService notifications)
{
_admin = admin;
_grantCalls = grantCalls;
_ideas = ideas;
_notifications = notifications;
}
[HttpGet("submissions")]
public async Task<ActionResult<IReadOnlyList<SubmissionResponse>>> ListSubmissions(
[FromQuery] string? status,
[FromQuery] string? challengeId,
CancellationToken cancellationToken)
{
var submissions = await _admin.ListSubmissionsAsync(status, challengeId, cancellationToken);
return Ok(submissions);
}
[HttpPut("knowledge-items/{knowledgeItemId}/verify")]
public async Task<ActionResult<KnowledgeItemResponse>> VerifyKnowledgeItem(
string knowledgeItemId,
VerifyKnowledgeItemRequest request,
CancellationToken cancellationToken)
{
var item = await _admin.VerifyKnowledgeItemAsync(knowledgeItemId, request, cancellationToken);
return item is null ? NotFound() : Ok(item);
}
[HttpGet("metrics")]
public async Task<ActionResult<AdminMetricsResponse>> Metrics(CancellationToken cancellationToken)
{
var metrics = await _admin.GetMetricsAsync(cancellationToken);
return Ok(metrics);
}
[HttpPut("grant-calls/{grantCallId}/status")]
public async Task<ActionResult<GrantCallResponse>> UpdateGrantCallStatus(
string grantCallId,
UpdateGrantCallStatusRequest request,
CancellationToken cancellationToken)
{
var updated = await _grantCalls.UpdateStatusAsync(grantCallId, request.Status, cancellationToken);
if (updated is null)
{
return NotFound();
}
if (string.Equals(request.Status, "open", StringComparison.Ordinal))
{
await NotifyIdeaAuthorsAsync(updated, cancellationToken);
}
return Ok(updated);
}
private async Task NotifyIdeaAuthorsAsync(GrantCallResponse call, CancellationToken cancellationToken)
{
var authorIds = await _ideas.ListDistinctAuthorIdsAsync(cancellationToken);
var payload = new Dictionary<string, string?>
{
["grantCallId"] = call.Id,
["name"] = call.Name,
};
foreach (var authorId in authorIds)
{
await _notifications.NotifyUserAsync(authorId, "grant_call.opened", payload, cancellationToken);
}
}
}
+2 -2
View File
@@ -48,7 +48,7 @@ public class ConsentsController : ControllerBase
userId, userId,
"consent.grant", "consent.grant",
consent.Id, consent.Id,
new Dictionary<string, object?> { ["scope"] = request.Scope }, new Dictionary<string, string?> { ["scope"] = request.Scope },
cancellationToken); cancellationToken);
return StatusCode(StatusCodes.Status201Created, consent); return StatusCode(StatusCodes.Status201Created, consent);
@@ -90,7 +90,7 @@ public class ConsentsController : ControllerBase
userId, userId,
"consent.revoke", "consent.revoke",
consentId, consentId,
new Dictionary<string, object?>(), new Dictionary<string, string?>(),
cancellationToken); cancellationToken);
return NoContent(); return NoContent();
+1 -2
View File
@@ -4,6 +4,7 @@ namespace TarninaHub.Controllers;
[ApiController] [ApiController]
[Route("api/health")] [Route("api/health")]
public class HealthController : ControllerBase public class HealthController : ControllerBase
{ {
[HttpGet] [HttpGet]
@@ -12,5 +13,3 @@ public class HealthController : ControllerBase
return Ok(new HealthResponse("ok")); return Ok(new HealthResponse("ok"));
} }
} }
public record HealthResponse(string Status);
+8
View File
@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace TarninaHub.Controllers;
[ApiController]
[Route("api/health")]
public record HealthResponse(string Status);
+16 -1
View File
@@ -17,11 +17,16 @@ public class IdeasController : ControllerBase
{ {
private readonly IIdeaService _ideaService; private readonly IIdeaService _ideaService;
private readonly IIdeaAssistant _ideaAssistant; private readonly IIdeaAssistant _ideaAssistant;
private readonly INotificationService _notifications;
public IdeasController(IIdeaService ideaService, IIdeaAssistant ideaAssistant) public IdeasController(
IIdeaService ideaService,
IIdeaAssistant ideaAssistant,
INotificationService notifications)
{ {
_ideaService = ideaService; _ideaService = ideaService;
_ideaAssistant = ideaAssistant; _ideaAssistant = ideaAssistant;
_notifications = notifications;
} }
[HttpPost] [HttpPost]
@@ -38,6 +43,16 @@ public class IdeasController : ControllerBase
var created = await _ideaService.CreateAsync(userId, request, cancellationToken); var created = await _ideaService.CreateAsync(userId, request, cancellationToken);
await _notifications.NotifyStaffAsync(
"idea.created",
new Dictionary<string, string?>
{
["ideaId"] = created.Id,
["title"] = created.Title,
["authorId"] = created.AuthorId,
},
cancellationToken);
return CreatedAtAction(nameof(GetById), new { ideaId = created.Id }, created); return CreatedAtAction(nameof(GetById), new { ideaId = created.Id }, created);
} }
+35 -1
View File
@@ -16,11 +16,16 @@ public class InnovationsController : ControllerBase
{ {
private readonly IInnovationService _innovations; private readonly IInnovationService _innovations;
private readonly IMatchmakingService _matchmakingService; private readonly IMatchmakingService _matchmakingService;
private readonly ITestFeedbackService _testFeedback;
public InnovationsController(IInnovationService innovations, IMatchmakingService matchmakingService) public InnovationsController(
IInnovationService innovations,
IMatchmakingService matchmakingService,
ITestFeedbackService testFeedback)
{ {
_innovations = innovations; _innovations = innovations;
_matchmakingService = matchmakingService; _matchmakingService = matchmakingService;
_testFeedback = testFeedback;
} }
[HttpGet] [HttpGet]
@@ -85,6 +90,35 @@ public class InnovationsController : ControllerBase
return updated is null ? NotFound() : Ok(updated); return updated is null ? NotFound() : Ok(updated);
} }
[HttpPost("{innovationId}/test-feedback")]
public async Task<ActionResult<TestFeedbackResponse>> CreateTestFeedback(
string innovationId,
CreateTestFeedbackRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var feedback = await _testFeedback.CreateAsync(innovationId, userId, request, cancellationToken);
return feedback is null ? NotFound() : StatusCode(StatusCodes.Status201Created, feedback);
}
[HttpGet("{innovationId}/test-feedback")]
[Authorize(Policy = Policies.ManagerOrAbove)]
public async Task<ActionResult<IReadOnlyList<TestFeedbackResponse>>> ListTestFeedback(
string innovationId,
CancellationToken cancellationToken)
{
var feedback = await _testFeedback.ListForInnovationAsync(innovationId, cancellationToken);
return Ok(feedback);
}
private bool ValidateStage(string? stage) => InnovationStageWire.IsValid(stage); private bool ValidateStage(string? stage) => InnovationStageWire.IsValid(stage);
private ActionResult InvalidStage() private ActionResult InvalidStage()
+1 -2
View File
@@ -10,6 +10,7 @@ namespace TarninaHub.Controllers;
[ApiVersion("1.0")] [ApiVersion("1.0")]
[Route("api/matchmaking")] [Route("api/matchmaking")]
[Authorize] [Authorize]
public class MatchmakingController : ControllerBase public class MatchmakingController : ControllerBase
{ {
private readonly IMatchmakingService _matchmakingService; private readonly IMatchmakingService _matchmakingService;
@@ -28,5 +29,3 @@ public class MatchmakingController : ControllerBase
return Ok(new MatchmakingReindexResponse(indexed)); return Ok(new MatchmakingReindexResponse(indexed));
} }
} }
public sealed record MatchmakingReindexResponse(int Indexed);
@@ -0,0 +1,14 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/matchmaking")]
[Authorize]
public sealed record MatchmakingReindexResponse(int Indexed);
@@ -0,0 +1,32 @@
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/middleman")]
[Authorize(Policy = Policies.JstOrAbove)]
public class MiddlemanController : ControllerBase
{
private readonly IMiddlemanService _middleman;
public MiddlemanController(IMiddlemanService middleman)
{
_middleman = middleman;
}
[HttpPost("adapt")]
public async Task<ActionResult<MiddlemanResponse>> Adapt(
MiddlemanRequest request,
CancellationToken cancellationToken)
{
var response = await _middleman.AdaptAsync(request, cancellationToken);
return response is null ? NotFound() : Ok(response);
}
}
@@ -0,0 +1,54 @@
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/notifications")]
[Authorize]
public class NotificationsController : ControllerBase
{
private readonly INotificationService _notifications;
public NotificationsController(INotificationService notifications)
{
_notifications = notifications;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<NotificationResponse>>> List(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var notifications = await _notifications.ListForUserAsync(userId, cancellationToken);
return Ok(notifications);
}
[HttpPost("{notificationId}/read")]
public async Task<ActionResult<NotificationResponse>> MarkRead(
string notificationId,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var notification = await _notifications.MarkReadAsync(notificationId, userId, cancellationToken);
return notification is null ? NotFound() : Ok(notification);
}
}
@@ -0,0 +1,57 @@
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/partnerships")]
[Authorize]
public class PartnershipsController : ControllerBase
{
private readonly IPartnershipService _partnerships;
public PartnershipsController(IPartnershipService partnerships)
{
_partnerships = partnerships;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<PartnershipResponse>>> List(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var partnerships = await _partnerships.ListForUserAsync(userId, IsStaff(), cancellationToken);
return Ok(partnerships);
}
[HttpPost]
public async Task<ActionResult<PartnershipResponse>> Create(
CreatePartnershipRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var created = await _partnerships.CreateAsync(userId, request, cancellationToken);
return StatusCode(StatusCodes.Status201Created, created);
}
private bool IsStaff() =>
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
}
+14 -1
View File
@@ -17,13 +17,16 @@ public class SubmissionsController : ControllerBase
{ {
private readonly ISubmissionService _submissionService; private readonly ISubmissionService _submissionService;
private readonly IMatchmakingService _matchmakingService; private readonly IMatchmakingService _matchmakingService;
private readonly INotificationService _notifications;
public SubmissionsController( public SubmissionsController(
ISubmissionService submissionService, ISubmissionService submissionService,
IMatchmakingService matchmakingService) IMatchmakingService matchmakingService,
INotificationService notifications)
{ {
_submissionService = submissionService; _submissionService = submissionService;
_matchmakingService = matchmakingService; _matchmakingService = matchmakingService;
_notifications = notifications;
} }
[HttpPost] [HttpPost]
@@ -42,6 +45,16 @@ public class SubmissionsController : ControllerBase
await _matchmakingService.IndexSubmissionAsync(created.Id, cancellationToken); await _matchmakingService.IndexSubmissionAsync(created.Id, cancellationToken);
await _notifications.NotifyStaffAsync(
"submission.created",
new Dictionary<string, string?>
{
["submissionId"] = created.Id,
["title"] = created.Title,
["authorId"] = created.AuthorId,
},
cancellationToken);
return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created); return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created);
} }
+101
View File
@@ -0,0 +1,101 @@
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/threads")]
[Authorize]
public class ThreadsController : ControllerBase
{
private readonly IThreadService _threads;
public ThreadsController(IThreadService threads)
{
_threads = threads;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ThreadResponse>>> List(CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var threads = await _threads.ListForUserAsync(userId, IsStaff(), cancellationToken);
return Ok(threads);
}
[HttpPost]
public async Task<ActionResult<ThreadResponse>> Create(
CreateThreadRequest request,
CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var created = await _threads.CreateAsync(userId, request, cancellationToken);
return CreatedAtAction(nameof(GetById), new { threadId = created.Id }, created);
}
[HttpGet("{threadId}")]
public async Task<ActionResult<ThreadResponse>> GetById(
string threadId,
CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var thread = await _threads.GetAsync(threadId, userId, IsStaff(), cancellationToken);
return thread is null ? NotFound() : Ok(thread);
}
[HttpPost("{threadId}/messages")]
public async Task<ActionResult<MessageResponse>> AddMessage(
string threadId,
CreateMessageRequest request,
CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var message = await _threads.AddMessageAsync(threadId, userId, IsStaff(), request, cancellationToken);
return message is null ? NotFound() : StatusCode(StatusCodes.Status201Created, message);
}
private string RequireUserId(out ActionResult? unauthorized)
{
var userId = User.GetUserId();
unauthorized = string.IsNullOrWhiteSpace(userId) ? Unauthorized() : null;
return userId ?? string.Empty;
}
private bool IsStaff() =>
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
}
+1 -1
View File
@@ -64,7 +64,7 @@ public class UsersController : ControllerBase
currentUserId, currentUserId,
"user.read", "user.read",
userId, userId,
new Dictionary<string, object?> { ["reason"] = "staff-lookup" }, new Dictionary<string, string?> { ["reason"] = "staff-lookup" },
cancellationToken); cancellationToken);
} }
@@ -0,0 +1,20 @@
using TarninaHub.Domain.Enums;
namespace TarninaHub.Domain.Rules;
public static class PartnershipWorkflow
{
private static readonly IReadOnlyDictionary<PartnershipStatus, PartnershipStatus[]> Transitions =
new Dictionary<PartnershipStatus, PartnershipStatus[]>
{
[PartnershipStatus.Proposed] = [PartnershipStatus.Active, PartnershipStatus.Closed],
[PartnershipStatus.Active] = [PartnershipStatus.Closed],
[PartnershipStatus.Closed] = [],
};
public static bool CanTransition(PartnershipStatus from, PartnershipStatus to) =>
from == to || Transitions[from].Contains(to);
public static bool IsTerminal(PartnershipStatus status) =>
Transitions[status].Length == 0;
}
@@ -0,0 +1,113 @@
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Matchmaking;
namespace TarninaHub.Infrastructure.Assist;
/// <summary>
/// Middleman Innowacji (F-8). Adapts an innovation to a service form for an
/// institution. Uses the configured LLM when available and a deterministic,
/// template-based rationale otherwise.
/// </summary>
public sealed class MiddlemanAssistant : IMiddlemanService
{
private const string SystemPrompt =
"Jesteś Middlemanem Innowacji w Małopolskim Hubie Innowacji Społecznych. " +
"Dostosowujesz sprawdzoną innowację do formy usługi dla instytucji (JST/NGO). " +
"Odpowiadaj prostym językiem, konkretnie (co/komu/jak) i nie wymyślaj faktów.";
private readonly IInnovationService _innovations;
private readonly ILlmProvider _llmProvider;
private readonly ILlmRequestLimiter _llmRequestLimiter;
public MiddlemanAssistant(
IInnovationService innovations,
ILlmProvider llmProvider,
ILlmRequestLimiter llmRequestLimiter)
{
_innovations = innovations;
_llmProvider = llmProvider;
_llmRequestLimiter = llmRequestLimiter;
}
public async Task<MiddlemanResponse?> AdaptAsync(
MiddlemanRequest request,
CancellationToken cancellationToken)
{
var innovation = await _innovations.GetAsync(request.InnovationId, cancellationToken);
if (innovation is null)
{
return null;
}
var serviceForm = BuildServiceForm(innovation, request);
var rationale = BuildRationale(innovation, request);
var fromLlm = false;
if (_llmProvider.IsConfigured && _llmRequestLimiter.TryAcquire())
{
var profile = request.InstitutionProfile;
var userPrompt =
$"Innowacja: {innovation.Title}\n" +
$"Streszczenie: {innovation.Summary}\n" +
$"Opis: {innovation.Description}\n" +
$"Etap: {innovation.Stage}\n" +
$"Instytucja: {profile?.Type ?? "nieokreślona"} (obszar: {profile?.Area ?? "ogólny"})\n" +
$"Odbiorcy: {(profile?.Audiences is { Count: > 0 } audiences ? string.Join(", ", audiences) : "nieokreśleni")}\n" +
$"Potrzeby: {request.Needs ?? "ogólne"}\n" +
"Zaproponuj formę usługi (co/komu/jak) i uzasadnienie.";
var completion = await _llmProvider.CompleteAsync(SystemPrompt, userPrompt, cancellationToken);
if (!string.IsNullOrWhiteSpace(completion))
{
serviceForm = completion.Trim();
fromLlm = true;
}
}
return new MiddlemanResponse(serviceForm, BuildResources(innovation), rationale, fromLlm);
}
private static string BuildServiceForm(InnovationResponse innovation, MiddlemanRequest request)
{
var audiences = request.InstitutionProfile?.Audiences is { Count: > 0 } values
? string.Join(", ", values)
: "odbiorcy wskazani przez instytucję";
var area = request.InstitutionProfile?.Area ?? innovation.ChallengeId;
return $"Co: {innovation.Title} — {innovation.Summary}. " +
$"Komu: {audiences}. " +
$"Jak: wdrożyć jako usługę w obszarze '{area}', etap '{innovation.Stage}', " +
$"z potrzeby: {request.Needs ?? "zdiagnozowanej lokalnie"}.";
}
private static string BuildRationale(InnovationResponse innovation, MiddlemanRequest request)
{
var institution = request.InstitutionProfile?.Type ?? "instytucja";
return $"Innowacja na etapie '{innovation.Stage}' odpowiada profilowi '{institution}' i obszarowi " +
$"'{request.InstitutionProfile?.Area ?? innovation.ChallengeId}', dlatego nadaje się do " +
"przekształcenia w powtarzalną usługę społeczną.";
}
private static IReadOnlyList<string> BuildResources(InnovationResponse innovation)
{
var resources = new List<string>
{
"Koordynator usługi i zespół realizacyjny",
"Budżet na pilotaż oraz pomiar efektów",
};
if (!string.IsNullOrWhiteSpace(innovation.Cost))
{
resources.Add($"Szacowany koszt: {innovation.Cost}");
}
resources.Add("Partnerzy lokalni (NGO/JST/firma) do wdrożenia");
return resources;
}
}

Some files were not shown because too many files have changed in this diff Show More