From fa1199539bd0c33bbfcc9ddeead48f01f9f43ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ty=C5=84ski?= Date: Sun, 4 Oct 2026 03:49:45 +0200 Subject: [PATCH] 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). --- .../Abstractions/AdminContracts.cs | 18 ++ .../Abstractions/CreateApplicationOutcome.cs | 12 + .../Abstractions/CreateApplicationResult.cs | 8 + .../Abstractions/IApplicationService.cs | 18 ++ .../Abstractions/IAuditLogService.cs | 2 +- .../Abstractions/IGrantCallService.cs | 16 ++ .../Abstractions/IIdeaAssistant.cs | 12 + .../Application/Abstractions/IIdeaService.cs | 21 ++ .../Abstractions/INotificationService.cs | 27 +++ .../Abstractions/ISubmissionEventPublisher.cs | 6 - .../Abstractions/IThreadService.cs | 30 +++ .../Application/Abstractions/ITokenService.cs | 3 +- .../Application/Abstractions/IdeaContracts.cs | 59 ----- .../Abstractions/MiddlemanContracts.cs | 8 + .../Abstractions/PartnershipContracts.cs | 16 ++ .../Abstractions/SubmissionEvent.cs | 9 + .../Abstractions/TesterContracts.cs | 16 ++ .../Application/Abstractions/TokenResult.cs | 6 + .../Application/Dtos/AdminMetricsResponse.cs | 14 ++ backend/Application/Dtos/ApplicationDtos.cs | 28 --- .../Application/Dtos/ApplicationResponse.cs | 12 + backend/Application/Dtos/AuthDtos.cs | 18 -- .../{ConsentDtos.cs => ConsentResponse.cs} | 1 - .../Dtos/CreateApplicationRequest.cs | 9 + backend/Application/Dtos/CreateIdeaRequest.cs | 9 + .../Application/Dtos/CreateMessageRequest.cs | 7 + .../Dtos/CreatePartnershipRequest.cs | 8 + .../Dtos/CreateSubmissionRequest.cs | 11 + .../Dtos/CreateTestFeedbackRequest.cs | 9 + .../Application/Dtos/CreateThreadRequest.cs | 8 + backend/Application/Dtos/GrantCallResponse.cs | 12 + .../Application/Dtos/GrantConsentRequest.cs | 4 + backend/Application/Dtos/IdeaAssistRequest.cs | 6 + .../Application/Dtos/IdeaAssistResponse.cs | 9 + backend/Application/Dtos/IdeaCardRequest.cs | 9 + backend/Application/Dtos/IdeaCardResponse.cs | 11 + backend/Application/Dtos/IdeaDtos.cs | 41 ---- backend/Application/Dtos/IdeaResponse.cs | 13 ++ backend/Application/Dtos/InnovationDtos.cs | 27 --- backend/Application/Dtos/InnovationRequest.cs | 15 ++ .../Application/Dtos/InnovationResponse.cs | 17 ++ .../Application/Dtos/InstitutionProfile.cs | 9 + backend/Application/Dtos/KnowledgeDtos.cs | 27 --- .../Application/Dtos/KnowledgeItemRequest.cs | 12 + .../Application/Dtos/KnowledgeItemResponse.cs | 14 ++ backend/Application/Dtos/LoginRequest.cs | 4 + backend/Application/Dtos/MatchRequest.cs | 6 + backend/Application/Dtos/MatchResponse.cs | 7 + backend/Application/Dtos/MatchmakingDtos.cs | 23 -- backend/Application/Dtos/MessageResponse.cs | 11 + backend/Application/Dtos/MiddlemanRequest.cs | 9 + backend/Application/Dtos/MiddlemanResponse.cs | 10 + .../Application/Dtos/NotificationResponse.cs | 11 + .../Application/Dtos/PartnershipResponse.cs | 11 + backend/Application/Dtos/RegisterRequest.cs | 4 + backend/Application/Dtos/SimilarCaseItem.cs | 7 + .../Dtos/SocialChallengeResponse.cs | 10 + backend/Application/Dtos/SolutionItem.cs | 10 + backend/Application/Dtos/StorageInfo.cs | 8 + backend/Application/Dtos/StorageModels.cs | 25 -- backend/Application/Dtos/StorageProbe.cs | 9 + .../Application/Dtos/StorageProbeResponse.cs | 6 + .../Application/Dtos/StorageStatusResponse.cs | 9 + ...ubmissionDtos.cs => SubmissionResponse.cs} | 9 - .../Application/Dtos/TestFeedbackResponse.cs | 13 ++ backend/Application/Dtos/ThreadResponse.cs | 11 + backend/Application/Dtos/TokenResponse.cs | 8 + backend/Application/Dtos/TrendPoint.cs | 7 + .../Dtos/{TrendsDtos.cs => TrendsResponse.cs} | 4 - .../Dtos/UpdateApplicationRequest.cs | 8 + .../Dtos/UpdateGrantCallStatusRequest.cs | 6 + backend/Application/Dtos/UpdateIdeaRequest.cs | 9 + .../Dtos/UpdateSubmissionStatusRequest.cs | 7 + backend/Application/Dtos/UserResponse.cs | 9 + .../Dtos/VerifyKnowledgeItemRequest.cs | 6 + .../Mappings/PartnershipStatusWire.cs | 22 ++ backend/Application/Matchmaking/LlmPrompt.cs | 4 + .../Matchmaking/MatchPromptBuilder.cs | 1 - backend/Application/Matchmaking/RankedItem.cs | 4 + .../Application/Matchmaking/SemanticRanker.cs | 1 - backend/Application/Security/ConsentScopes.cs | 16 ++ backend/Application/Security/Policies.cs | 20 ++ backend/Application/Security/RoleNames.cs | 16 ++ .../Application/Security/SecurityConstants.cs | 47 ---- backend/Controllers/AdminController.cs | 98 ++++++++ backend/Controllers/ConsentsController.cs | 4 +- backend/Controllers/HealthController.cs | 3 +- backend/Controllers/HealthResponse.cs | 8 + backend/Controllers/IdeasController.cs | 17 +- backend/Controllers/InnovationsController.cs | 36 ++- backend/Controllers/MatchmakingController.cs | 3 +- .../Controllers/MatchmakingReindexResponse.cs | 14 ++ backend/Controllers/MiddlemanController.cs | 32 +++ .../Controllers/NotificationsController.cs | 54 +++++ backend/Controllers/PartnershipsController.cs | 57 +++++ backend/Controllers/SubmissionsController.cs | 15 +- backend/Controllers/ThreadsController.cs | 101 ++++++++ backend/Controllers/UsersController.cs | 2 +- backend/Domain/Rules/PartnershipWorkflow.cs | 20 ++ .../Assist/MiddlemanAssistant.cs | 113 +++++++++ backend/Infrastructure/DependencyInjection.cs | 6 + .../Persistence/AdminService.cs | 96 ++++++++ .../Persistence/ApplicationRow.cs | 19 ++ .../Persistence/ApplicationService.cs | 21 +- .../Persistence/AuditLogService.cs | 2 +- .../Persistence/ChallengeRow.cs | 15 ++ .../Infrastructure/Persistence/ConsentRow.cs | 19 ++ .../Infrastructure/Persistence/CountRow.cs | 9 + .../Persistence/GrantCallRow.cs | 19 ++ .../Persistence/GrantCallService.cs | 24 ++ backend/Infrastructure/Persistence/IdRow.cs | 9 + .../Infrastructure/Persistence/IdeaCardRow.cs | 17 ++ backend/Infrastructure/Persistence/IdeaRow.cs | 19 ++ .../Infrastructure/Persistence/IdeaService.cs | 13 ++ .../Persistence/InnovationRow.cs | 31 +++ .../Persistence/InnovationService.cs | 16 +- .../Persistence/KnowledgeItemRow.cs | 23 ++ .../Persistence/KnowledgeItemService.cs | 10 +- .../Infrastructure/Persistence/MessageRow.cs | 17 ++ .../Persistence/NotificationRow.cs | 17 ++ .../Persistence/NotificationService.cs | 115 ++++++++++ .../Persistence/PartnershipRow.cs | 17 ++ .../Persistence/PartnershipService.cs | 95 ++++++++ .../Persistence/PasswordCheckRow.cs | 9 + .../Infrastructure/Persistence/ProblemRow.cs | 21 ++ .../Persistence/SubmissionRow.cs | 29 +++ .../Infrastructure/Persistence/SurrealRows.cs | 215 ------------------ .../Persistence/TestFeedbackRow.cs | 21 ++ .../Persistence/TestFeedbackService.cs | 90 ++++++++ .../Infrastructure/Persistence/ThreadRow.cs | 15 ++ .../Persistence/ThreadService.cs | 204 +++++++++++++++++ .../Infrastructure/Persistence/TrendRow.cs | 11 + .../Persistence/TrendSubmissionRow.cs | 9 + backend/Infrastructure/Persistence/UserRow.cs | 17 ++ .../0006_notification_payload.surql | 5 + .../migrations/0007_innovation_contact.surql | 5 + 136 files changed, 2338 insertions(+), 574 deletions(-) create mode 100644 backend/Application/Abstractions/AdminContracts.cs create mode 100644 backend/Application/Abstractions/CreateApplicationOutcome.cs create mode 100644 backend/Application/Abstractions/CreateApplicationResult.cs create mode 100644 backend/Application/Abstractions/IApplicationService.cs create mode 100644 backend/Application/Abstractions/IGrantCallService.cs create mode 100644 backend/Application/Abstractions/IIdeaAssistant.cs create mode 100644 backend/Application/Abstractions/IIdeaService.cs create mode 100644 backend/Application/Abstractions/INotificationService.cs create mode 100644 backend/Application/Abstractions/IThreadService.cs delete mode 100644 backend/Application/Abstractions/IdeaContracts.cs create mode 100644 backend/Application/Abstractions/MiddlemanContracts.cs create mode 100644 backend/Application/Abstractions/PartnershipContracts.cs create mode 100644 backend/Application/Abstractions/SubmissionEvent.cs create mode 100644 backend/Application/Abstractions/TesterContracts.cs create mode 100644 backend/Application/Abstractions/TokenResult.cs create mode 100644 backend/Application/Dtos/AdminMetricsResponse.cs delete mode 100644 backend/Application/Dtos/ApplicationDtos.cs create mode 100644 backend/Application/Dtos/ApplicationResponse.cs delete mode 100644 backend/Application/Dtos/AuthDtos.cs rename backend/Application/Dtos/{ConsentDtos.cs => ConsentResponse.cs} (72%) create mode 100644 backend/Application/Dtos/CreateApplicationRequest.cs create mode 100644 backend/Application/Dtos/CreateIdeaRequest.cs create mode 100644 backend/Application/Dtos/CreateMessageRequest.cs create mode 100644 backend/Application/Dtos/CreatePartnershipRequest.cs create mode 100644 backend/Application/Dtos/CreateSubmissionRequest.cs create mode 100644 backend/Application/Dtos/CreateTestFeedbackRequest.cs create mode 100644 backend/Application/Dtos/CreateThreadRequest.cs create mode 100644 backend/Application/Dtos/GrantCallResponse.cs create mode 100644 backend/Application/Dtos/GrantConsentRequest.cs create mode 100644 backend/Application/Dtos/IdeaAssistRequest.cs create mode 100644 backend/Application/Dtos/IdeaAssistResponse.cs create mode 100644 backend/Application/Dtos/IdeaCardRequest.cs create mode 100644 backend/Application/Dtos/IdeaCardResponse.cs delete mode 100644 backend/Application/Dtos/IdeaDtos.cs create mode 100644 backend/Application/Dtos/IdeaResponse.cs delete mode 100644 backend/Application/Dtos/InnovationDtos.cs create mode 100644 backend/Application/Dtos/InnovationRequest.cs create mode 100644 backend/Application/Dtos/InnovationResponse.cs create mode 100644 backend/Application/Dtos/InstitutionProfile.cs delete mode 100644 backend/Application/Dtos/KnowledgeDtos.cs create mode 100644 backend/Application/Dtos/KnowledgeItemRequest.cs create mode 100644 backend/Application/Dtos/KnowledgeItemResponse.cs create mode 100644 backend/Application/Dtos/LoginRequest.cs create mode 100644 backend/Application/Dtos/MatchRequest.cs create mode 100644 backend/Application/Dtos/MatchResponse.cs delete mode 100644 backend/Application/Dtos/MatchmakingDtos.cs create mode 100644 backend/Application/Dtos/MessageResponse.cs create mode 100644 backend/Application/Dtos/MiddlemanRequest.cs create mode 100644 backend/Application/Dtos/MiddlemanResponse.cs create mode 100644 backend/Application/Dtos/NotificationResponse.cs create mode 100644 backend/Application/Dtos/PartnershipResponse.cs create mode 100644 backend/Application/Dtos/RegisterRequest.cs create mode 100644 backend/Application/Dtos/SimilarCaseItem.cs create mode 100644 backend/Application/Dtos/SocialChallengeResponse.cs create mode 100644 backend/Application/Dtos/SolutionItem.cs create mode 100644 backend/Application/Dtos/StorageInfo.cs delete mode 100644 backend/Application/Dtos/StorageModels.cs create mode 100644 backend/Application/Dtos/StorageProbe.cs create mode 100644 backend/Application/Dtos/StorageProbeResponse.cs create mode 100644 backend/Application/Dtos/StorageStatusResponse.cs rename backend/Application/Dtos/{SubmissionDtos.cs => SubmissionResponse.cs} (50%) create mode 100644 backend/Application/Dtos/TestFeedbackResponse.cs create mode 100644 backend/Application/Dtos/ThreadResponse.cs create mode 100644 backend/Application/Dtos/TokenResponse.cs create mode 100644 backend/Application/Dtos/TrendPoint.cs rename backend/Application/Dtos/{TrendsDtos.cs => TrendsResponse.cs} (60%) create mode 100644 backend/Application/Dtos/UpdateApplicationRequest.cs create mode 100644 backend/Application/Dtos/UpdateGrantCallStatusRequest.cs create mode 100644 backend/Application/Dtos/UpdateIdeaRequest.cs create mode 100644 backend/Application/Dtos/UpdateSubmissionStatusRequest.cs create mode 100644 backend/Application/Dtos/UserResponse.cs create mode 100644 backend/Application/Dtos/VerifyKnowledgeItemRequest.cs create mode 100644 backend/Application/Mappings/PartnershipStatusWire.cs create mode 100644 backend/Application/Matchmaking/LlmPrompt.cs create mode 100644 backend/Application/Matchmaking/RankedItem.cs create mode 100644 backend/Application/Security/ConsentScopes.cs create mode 100644 backend/Application/Security/Policies.cs create mode 100644 backend/Application/Security/RoleNames.cs delete mode 100644 backend/Application/Security/SecurityConstants.cs create mode 100644 backend/Controllers/AdminController.cs create mode 100644 backend/Controllers/HealthResponse.cs create mode 100644 backend/Controllers/MatchmakingReindexResponse.cs create mode 100644 backend/Controllers/MiddlemanController.cs create mode 100644 backend/Controllers/NotificationsController.cs create mode 100644 backend/Controllers/PartnershipsController.cs create mode 100644 backend/Controllers/ThreadsController.cs create mode 100644 backend/Domain/Rules/PartnershipWorkflow.cs create mode 100644 backend/Infrastructure/Assist/MiddlemanAssistant.cs create mode 100644 backend/Infrastructure/Persistence/AdminService.cs create mode 100644 backend/Infrastructure/Persistence/ApplicationRow.cs create mode 100644 backend/Infrastructure/Persistence/ChallengeRow.cs create mode 100644 backend/Infrastructure/Persistence/ConsentRow.cs create mode 100644 backend/Infrastructure/Persistence/CountRow.cs create mode 100644 backend/Infrastructure/Persistence/GrantCallRow.cs create mode 100644 backend/Infrastructure/Persistence/IdRow.cs create mode 100644 backend/Infrastructure/Persistence/IdeaCardRow.cs create mode 100644 backend/Infrastructure/Persistence/IdeaRow.cs create mode 100644 backend/Infrastructure/Persistence/InnovationRow.cs create mode 100644 backend/Infrastructure/Persistence/KnowledgeItemRow.cs create mode 100644 backend/Infrastructure/Persistence/MessageRow.cs create mode 100644 backend/Infrastructure/Persistence/NotificationRow.cs create mode 100644 backend/Infrastructure/Persistence/NotificationService.cs create mode 100644 backend/Infrastructure/Persistence/PartnershipRow.cs create mode 100644 backend/Infrastructure/Persistence/PartnershipService.cs create mode 100644 backend/Infrastructure/Persistence/PasswordCheckRow.cs create mode 100644 backend/Infrastructure/Persistence/ProblemRow.cs create mode 100644 backend/Infrastructure/Persistence/SubmissionRow.cs delete mode 100644 backend/Infrastructure/Persistence/SurrealRows.cs create mode 100644 backend/Infrastructure/Persistence/TestFeedbackRow.cs create mode 100644 backend/Infrastructure/Persistence/TestFeedbackService.cs create mode 100644 backend/Infrastructure/Persistence/ThreadRow.cs create mode 100644 backend/Infrastructure/Persistence/ThreadService.cs create mode 100644 backend/Infrastructure/Persistence/TrendRow.cs create mode 100644 backend/Infrastructure/Persistence/TrendSubmissionRow.cs create mode 100644 backend/Infrastructure/Persistence/UserRow.cs create mode 100644 backend/migrations/0006_notification_payload.surql create mode 100644 backend/migrations/0007_innovation_contact.surql diff --git a/backend/Application/Abstractions/AdminContracts.cs b/backend/Application/Abstractions/AdminContracts.cs new file mode 100644 index 0000000..2b2e3b5 --- /dev/null +++ b/backend/Application/Abstractions/AdminContracts.cs @@ -0,0 +1,18 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IAdminService +{ + Task> ListSubmissionsAsync( + string? status, + string? challengeId, + CancellationToken cancellationToken); + + Task VerifyKnowledgeItemAsync( + string knowledgeItemId, + VerifyKnowledgeItemRequest request, + CancellationToken cancellationToken); + + Task GetMetricsAsync(CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/CreateApplicationOutcome.cs b/backend/Application/Abstractions/CreateApplicationOutcome.cs new file mode 100644 index 0000000..a67472b --- /dev/null +++ b/backend/Application/Abstractions/CreateApplicationOutcome.cs @@ -0,0 +1,12 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + + +public enum CreateApplicationOutcome +{ + Created, + CallNotFound, + CallNotOpen, + IdeaNotFound, +} diff --git a/backend/Application/Abstractions/CreateApplicationResult.cs b/backend/Application/Abstractions/CreateApplicationResult.cs new file mode 100644 index 0000000..366330c --- /dev/null +++ b/backend/Application/Abstractions/CreateApplicationResult.cs @@ -0,0 +1,8 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + + +public sealed record CreateApplicationResult( + CreateApplicationOutcome Outcome, + ApplicationResponse? Application); diff --git a/backend/Application/Abstractions/IApplicationService.cs b/backend/Application/Abstractions/IApplicationService.cs new file mode 100644 index 0000000..a65c17b --- /dev/null +++ b/backend/Application/Abstractions/IApplicationService.cs @@ -0,0 +1,18 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + + +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/Abstractions/IAuditLogService.cs b/backend/Application/Abstractions/IAuditLogService.cs index d7626a9..45f1a66 100644 --- a/backend/Application/Abstractions/IAuditLogService.cs +++ b/backend/Application/Abstractions/IAuditLogService.cs @@ -6,6 +6,6 @@ public interface IAuditLogService string? actorId, string action, string target, - IReadOnlyDictionary context, + IReadOnlyDictionary context, CancellationToken cancellationToken); } diff --git a/backend/Application/Abstractions/IGrantCallService.cs b/backend/Application/Abstractions/IGrantCallService.cs new file mode 100644 index 0000000..afec320 --- /dev/null +++ b/backend/Application/Abstractions/IGrantCallService.cs @@ -0,0 +1,16 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + + +public interface IGrantCallService +{ + Task> ListAsync(string? status, CancellationToken cancellationToken); + + Task GetAsync(string grantCallId, CancellationToken cancellationToken); + + Task UpdateStatusAsync( + string grantCallId, + string status, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/IIdeaAssistant.cs b/backend/Application/Abstractions/IIdeaAssistant.cs new file mode 100644 index 0000000..2521458 --- /dev/null +++ b/backend/Application/Abstractions/IIdeaAssistant.cs @@ -0,0 +1,12 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + + +public interface IIdeaAssistant +{ + Task AssistAsync( + string ideaId, + IdeaAssistRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/IIdeaService.cs b/backend/Application/Abstractions/IIdeaService.cs new file mode 100644 index 0000000..07030d9 --- /dev/null +++ b/backend/Application/Abstractions/IIdeaService.cs @@ -0,0 +1,21 @@ +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> ListDistinctAuthorIdsAsync(CancellationToken cancellationToken); + + Task UpdateAsync(string ideaId, UpdateIdeaRequest request, CancellationToken cancellationToken); + + Task GetCardAsync(string ideaId, CancellationToken cancellationToken); + + Task UpdateCardAsync(string ideaId, IdeaCardRequest request, CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/INotificationService.cs b/backend/Application/Abstractions/INotificationService.cs new file mode 100644 index 0000000..a84ddd0 --- /dev/null +++ b/backend/Application/Abstractions/INotificationService.cs @@ -0,0 +1,27 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + + +public interface INotificationService +{ + Task NotifyStaffAsync( + string type, + IReadOnlyDictionary payload, + CancellationToken cancellationToken); + + Task NotifyUserAsync( + string userId, + string type, + IReadOnlyDictionary payload, + CancellationToken cancellationToken); + + Task> ListForUserAsync( + string userId, + CancellationToken cancellationToken); + + Task MarkReadAsync( + string notificationId, + string userId, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/ISubmissionEventPublisher.cs b/backend/Application/Abstractions/ISubmissionEventPublisher.cs index f131047..3536074 100644 --- a/backend/Application/Abstractions/ISubmissionEventPublisher.cs +++ b/backend/Application/Abstractions/ISubmissionEventPublisher.cs @@ -1,11 +1,5 @@ namespace TarninaHub.Application.Abstractions; -public sealed record SubmissionEvent( - string Type, - string SubmissionId, - string? ActorId, - string Status, - DateTimeOffset At); public interface ISubmissionEventPublisher { diff --git a/backend/Application/Abstractions/IThreadService.cs b/backend/Application/Abstractions/IThreadService.cs new file mode 100644 index 0000000..f75434b --- /dev/null +++ b/backend/Application/Abstractions/IThreadService.cs @@ -0,0 +1,30 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + + +public interface IThreadService +{ + Task> ListForUserAsync( + string userId, + bool isStaff, + CancellationToken cancellationToken); + + Task GetAsync( + string threadId, + string userId, + bool isStaff, + CancellationToken cancellationToken); + + Task CreateAsync( + string creatorId, + CreateThreadRequest request, + CancellationToken cancellationToken); + + Task AddMessageAsync( + string threadId, + string authorId, + bool isStaff, + CreateMessageRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/ITokenService.cs b/backend/Application/Abstractions/ITokenService.cs index ebeb5dc..45f5239 100644 --- a/backend/Application/Abstractions/ITokenService.cs +++ b/backend/Application/Abstractions/ITokenService.cs @@ -2,9 +2,8 @@ using TarninaHub.Application.Models; namespace TarninaHub.Application.Abstractions; + public interface ITokenService { TokenResult CreateToken(UserAccount user); } - -public sealed record TokenResult(string AccessToken, DateTimeOffset ExpiresAt); diff --git a/backend/Application/Abstractions/IdeaContracts.cs b/backend/Application/Abstractions/IdeaContracts.cs deleted file mode 100644 index 169f420..0000000 --- a/backend/Application/Abstractions/IdeaContracts.cs +++ /dev/null @@ -1,59 +0,0 @@ -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/Abstractions/MiddlemanContracts.cs b/backend/Application/Abstractions/MiddlemanContracts.cs new file mode 100644 index 0000000..5ca5ba4 --- /dev/null +++ b/backend/Application/Abstractions/MiddlemanContracts.cs @@ -0,0 +1,8 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IMiddlemanService +{ + Task AdaptAsync(MiddlemanRequest request, CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/PartnershipContracts.cs b/backend/Application/Abstractions/PartnershipContracts.cs new file mode 100644 index 0000000..0d65d50 --- /dev/null +++ b/backend/Application/Abstractions/PartnershipContracts.cs @@ -0,0 +1,16 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IPartnershipService +{ + Task> ListForUserAsync( + string userId, + bool isStaff, + CancellationToken cancellationToken); + + Task CreateAsync( + string initiatorId, + CreatePartnershipRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/SubmissionEvent.cs b/backend/Application/Abstractions/SubmissionEvent.cs new file mode 100644 index 0000000..32f16e6 --- /dev/null +++ b/backend/Application/Abstractions/SubmissionEvent.cs @@ -0,0 +1,9 @@ +namespace TarninaHub.Application.Abstractions; + + +public sealed record SubmissionEvent( + string Type, + string SubmissionId, + string? ActorId, + string Status, + DateTimeOffset At); diff --git a/backend/Application/Abstractions/TesterContracts.cs b/backend/Application/Abstractions/TesterContracts.cs new file mode 100644 index 0000000..fd77630 --- /dev/null +++ b/backend/Application/Abstractions/TesterContracts.cs @@ -0,0 +1,16 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface ITestFeedbackService +{ + Task CreateAsync( + string innovationId, + string authorId, + CreateTestFeedbackRequest request, + CancellationToken cancellationToken); + + Task> ListForInnovationAsync( + string innovationId, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/TokenResult.cs b/backend/Application/Abstractions/TokenResult.cs new file mode 100644 index 0000000..0bb867f --- /dev/null +++ b/backend/Application/Abstractions/TokenResult.cs @@ -0,0 +1,6 @@ +using TarninaHub.Application.Models; + +namespace TarninaHub.Application.Abstractions; + + +public sealed record TokenResult(string AccessToken, DateTimeOffset ExpiresAt); diff --git a/backend/Application/Dtos/AdminMetricsResponse.cs b/backend/Application/Dtos/AdminMetricsResponse.cs new file mode 100644 index 0000000..6b9d38c --- /dev/null +++ b/backend/Application/Dtos/AdminMetricsResponse.cs @@ -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); diff --git a/backend/Application/Dtos/ApplicationDtos.cs b/backend/Application/Dtos/ApplicationDtos.cs deleted file mode 100644 index 59c701a..0000000 --- a/backend/Application/Dtos/ApplicationDtos.cs +++ /dev/null @@ -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? 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/ApplicationResponse.cs b/backend/Application/Dtos/ApplicationResponse.cs new file mode 100644 index 0000000..4a9d68c --- /dev/null +++ b/backend/Application/Dtos/ApplicationResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record ApplicationResponse( + string Id, + string GrantCallId, + string IdeaId, + IReadOnlyDictionary Content, + string Status, + DateTimeOffset CreatedAt); diff --git a/backend/Application/Dtos/AuthDtos.cs b/backend/Application/Dtos/AuthDtos.cs deleted file mode 100644 index 087dfb8..0000000 --- a/backend/Application/Dtos/AuthDtos.cs +++ /dev/null @@ -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); diff --git a/backend/Application/Dtos/ConsentDtos.cs b/backend/Application/Dtos/ConsentResponse.cs similarity index 72% rename from backend/Application/Dtos/ConsentDtos.cs rename to backend/Application/Dtos/ConsentResponse.cs index 18f1315..f19be16 100644 --- a/backend/Application/Dtos/ConsentDtos.cs +++ b/backend/Application/Dtos/ConsentResponse.cs @@ -1,6 +1,5 @@ namespace TarninaHub.Application.Dtos; -public sealed record GrantConsentRequest(string Scope, DateTimeOffset? ExpiresAt); public sealed record ConsentResponse( string Id, diff --git a/backend/Application/Dtos/CreateApplicationRequest.cs b/backend/Application/Dtos/CreateApplicationRequest.cs new file mode 100644 index 0000000..f403b3d --- /dev/null +++ b/backend/Application/Dtos/CreateApplicationRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record CreateApplicationRequest( + [Required] string GrantCallId, + [Required] string IdeaId, + IReadOnlyDictionary? Content); diff --git a/backend/Application/Dtos/CreateIdeaRequest.cs b/backend/Application/Dtos/CreateIdeaRequest.cs new file mode 100644 index 0000000..401cc0d --- /dev/null +++ b/backend/Application/Dtos/CreateIdeaRequest.cs @@ -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); diff --git a/backend/Application/Dtos/CreateMessageRequest.cs b/backend/Application/Dtos/CreateMessageRequest.cs new file mode 100644 index 0000000..e081e07 --- /dev/null +++ b/backend/Application/Dtos/CreateMessageRequest.cs @@ -0,0 +1,7 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record CreateMessageRequest( + [Required, StringLength(4000, MinimumLength = 1)] string Body); diff --git a/backend/Application/Dtos/CreatePartnershipRequest.cs b/backend/Application/Dtos/CreatePartnershipRequest.cs new file mode 100644 index 0000000..a3d158a --- /dev/null +++ b/backend/Application/Dtos/CreatePartnershipRequest.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record CreatePartnershipRequest( + [Required, StringLength(200, MinimumLength = 3)] string Subject, + IReadOnlyList? Partners); diff --git a/backend/Application/Dtos/CreateSubmissionRequest.cs b/backend/Application/Dtos/CreateSubmissionRequest.cs new file mode 100644 index 0000000..83435bd --- /dev/null +++ b/backend/Application/Dtos/CreateSubmissionRequest.cs @@ -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? Keywords); diff --git a/backend/Application/Dtos/CreateTestFeedbackRequest.cs b/backend/Application/Dtos/CreateTestFeedbackRequest.cs new file mode 100644 index 0000000..0bbae10 --- /dev/null +++ b/backend/Application/Dtos/CreateTestFeedbackRequest.cs @@ -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); diff --git a/backend/Application/Dtos/CreateThreadRequest.cs b/backend/Application/Dtos/CreateThreadRequest.cs new file mode 100644 index 0000000..53553aa --- /dev/null +++ b/backend/Application/Dtos/CreateThreadRequest.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record CreateThreadRequest( + [Required, StringLength(200, MinimumLength = 3)] string Subject, + IReadOnlyList? Participants); diff --git a/backend/Application/Dtos/GrantCallResponse.cs b/backend/Application/Dtos/GrantCallResponse.cs new file mode 100644 index 0000000..8011776 --- /dev/null +++ b/backend/Application/Dtos/GrantCallResponse.cs @@ -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); diff --git a/backend/Application/Dtos/GrantConsentRequest.cs b/backend/Application/Dtos/GrantConsentRequest.cs new file mode 100644 index 0000000..6fe129a --- /dev/null +++ b/backend/Application/Dtos/GrantConsentRequest.cs @@ -0,0 +1,4 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record GrantConsentRequest(string Scope, DateTimeOffset? ExpiresAt); diff --git a/backend/Application/Dtos/IdeaAssistRequest.cs b/backend/Application/Dtos/IdeaAssistRequest.cs new file mode 100644 index 0000000..1446a81 --- /dev/null +++ b/backend/Application/Dtos/IdeaAssistRequest.cs @@ -0,0 +1,6 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record IdeaAssistRequest(string? Focus); diff --git a/backend/Application/Dtos/IdeaAssistResponse.cs b/backend/Application/Dtos/IdeaAssistResponse.cs new file mode 100644 index 0000000..9807dfb --- /dev/null +++ b/backend/Application/Dtos/IdeaAssistResponse.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record IdeaAssistResponse( + IReadOnlyList Suggestions, + string ImprovedDescription, + bool FromLlm); diff --git a/backend/Application/Dtos/IdeaCardRequest.cs b/backend/Application/Dtos/IdeaCardRequest.cs new file mode 100644 index 0000000..47c0e4d --- /dev/null +++ b/backend/Application/Dtos/IdeaCardRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record IdeaCardRequest( + string? Essence, + string? Audience, + string? Stage); diff --git a/backend/Application/Dtos/IdeaCardResponse.cs b/backend/Application/Dtos/IdeaCardResponse.cs new file mode 100644 index 0000000..3ef6571 --- /dev/null +++ b/backend/Application/Dtos/IdeaCardResponse.cs @@ -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); diff --git a/backend/Application/Dtos/IdeaDtos.cs b/backend/Application/Dtos/IdeaDtos.cs deleted file mode 100644 index 81601f7..0000000 --- a/backend/Application/Dtos/IdeaDtos.cs +++ /dev/null @@ -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 Suggestions, - string ImprovedDescription, - bool FromLlm); diff --git a/backend/Application/Dtos/IdeaResponse.cs b/backend/Application/Dtos/IdeaResponse.cs new file mode 100644 index 0000000..4f971ef --- /dev/null +++ b/backend/Application/Dtos/IdeaResponse.cs @@ -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); diff --git a/backend/Application/Dtos/InnovationDtos.cs b/backend/Application/Dtos/InnovationDtos.cs deleted file mode 100644 index abf1db2..0000000 --- a/backend/Application/Dtos/InnovationDtos.cs +++ /dev/null @@ -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? Contact, - IReadOnlyList>? Media, - bool Published); - -public sealed record InnovationResponse( - string Id, - string Title, - string Summary, - string Description, - string ChallengeId, - string Stage, - string? Cost, - IReadOnlyDictionary? Contact, - IReadOnlyList> Media, - bool Published, - DateTimeOffset? UpdatedAt); diff --git a/backend/Application/Dtos/InnovationRequest.cs b/backend/Application/Dtos/InnovationRequest.cs new file mode 100644 index 0000000..fa8517b --- /dev/null +++ b/backend/Application/Dtos/InnovationRequest.cs @@ -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? Contact, + IReadOnlyList>? Media, + bool Published); diff --git a/backend/Application/Dtos/InnovationResponse.cs b/backend/Application/Dtos/InnovationResponse.cs new file mode 100644 index 0000000..2d03e40 --- /dev/null +++ b/backend/Application/Dtos/InnovationResponse.cs @@ -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? Contact, + IReadOnlyList> Media, + bool Published, + DateTimeOffset? UpdatedAt); diff --git a/backend/Application/Dtos/InstitutionProfile.cs b/backend/Application/Dtos/InstitutionProfile.cs new file mode 100644 index 0000000..1e45f60 --- /dev/null +++ b/backend/Application/Dtos/InstitutionProfile.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record InstitutionProfile( + string? Type, + string? Area, + IReadOnlyList? Audiences); diff --git a/backend/Application/Dtos/KnowledgeDtos.cs b/backend/Application/Dtos/KnowledgeDtos.cs deleted file mode 100644 index 508cd9b..0000000 --- a/backend/Application/Dtos/KnowledgeDtos.cs +++ /dev/null @@ -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>? Attachments, - bool Published); - -public sealed record KnowledgeItemResponse( - string Id, - string Title, - string Kind, - string? ChallengeId, - string? SourceUrl, - IReadOnlyList> Attachments, - bool Published, - DateTimeOffset? UpdatedAt); - -public sealed record SocialChallengeResponse( - string Id, - string Name, - string Description, - int Order); diff --git a/backend/Application/Dtos/KnowledgeItemRequest.cs b/backend/Application/Dtos/KnowledgeItemRequest.cs new file mode 100644 index 0000000..2b66123 --- /dev/null +++ b/backend/Application/Dtos/KnowledgeItemRequest.cs @@ -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>? Attachments, + bool Published); diff --git a/backend/Application/Dtos/KnowledgeItemResponse.cs b/backend/Application/Dtos/KnowledgeItemResponse.cs new file mode 100644 index 0000000..f0f1a43 --- /dev/null +++ b/backend/Application/Dtos/KnowledgeItemResponse.cs @@ -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> Attachments, + bool Published, + DateTimeOffset? UpdatedAt); diff --git a/backend/Application/Dtos/LoginRequest.cs b/backend/Application/Dtos/LoginRequest.cs new file mode 100644 index 0000000..2517071 --- /dev/null +++ b/backend/Application/Dtos/LoginRequest.cs @@ -0,0 +1,4 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record LoginRequest(string Phone, string Password); diff --git a/backend/Application/Dtos/MatchRequest.cs b/backend/Application/Dtos/MatchRequest.cs new file mode 100644 index 0000000..9e8990e --- /dev/null +++ b/backend/Application/Dtos/MatchRequest.cs @@ -0,0 +1,6 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record MatchRequest( + int? MaxResults, + string? ChallengeId); diff --git a/backend/Application/Dtos/MatchResponse.cs b/backend/Application/Dtos/MatchResponse.cs new file mode 100644 index 0000000..0eb1490 --- /dev/null +++ b/backend/Application/Dtos/MatchResponse.cs @@ -0,0 +1,7 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record MatchResponse( + IReadOnlyList SimilarCases, + IReadOnlyList Solutions, + bool Fallback); diff --git a/backend/Application/Dtos/MatchmakingDtos.cs b/backend/Application/Dtos/MatchmakingDtos.cs deleted file mode 100644 index 0e602c2..0000000 --- a/backend/Application/Dtos/MatchmakingDtos.cs +++ /dev/null @@ -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 SimilarCases, - IReadOnlyList Solutions, - bool Fallback); diff --git a/backend/Application/Dtos/MessageResponse.cs b/backend/Application/Dtos/MessageResponse.cs new file mode 100644 index 0000000..f66a7f3 --- /dev/null +++ b/backend/Application/Dtos/MessageResponse.cs @@ -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); diff --git a/backend/Application/Dtos/MiddlemanRequest.cs b/backend/Application/Dtos/MiddlemanRequest.cs new file mode 100644 index 0000000..3de7bb6 --- /dev/null +++ b/backend/Application/Dtos/MiddlemanRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record MiddlemanRequest( + [Required] string InnovationId, + InstitutionProfile? InstitutionProfile, + string? Needs); diff --git a/backend/Application/Dtos/MiddlemanResponse.cs b/backend/Application/Dtos/MiddlemanResponse.cs new file mode 100644 index 0000000..72f31bc --- /dev/null +++ b/backend/Application/Dtos/MiddlemanResponse.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record MiddlemanResponse( + string ServiceForm, + IReadOnlyList RequiredResources, + string Rationale, + bool FromLlm); diff --git a/backend/Application/Dtos/NotificationResponse.cs b/backend/Application/Dtos/NotificationResponse.cs new file mode 100644 index 0000000..8de68f3 --- /dev/null +++ b/backend/Application/Dtos/NotificationResponse.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record NotificationResponse( + string Id, + string Type, + IReadOnlyDictionary Payload, + DateTimeOffset? ReadAt, + DateTimeOffset CreatedAt); diff --git a/backend/Application/Dtos/PartnershipResponse.cs b/backend/Application/Dtos/PartnershipResponse.cs new file mode 100644 index 0000000..2de22fd --- /dev/null +++ b/backend/Application/Dtos/PartnershipResponse.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record PartnershipResponse( + string Id, + string InitiatorId, + string Subject, + IReadOnlyList Partners, + string Status); diff --git a/backend/Application/Dtos/RegisterRequest.cs b/backend/Application/Dtos/RegisterRequest.cs new file mode 100644 index 0000000..30fdbb5 --- /dev/null +++ b/backend/Application/Dtos/RegisterRequest.cs @@ -0,0 +1,4 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record RegisterRequest(string Phone, string? Email, string Password); diff --git a/backend/Application/Dtos/SimilarCaseItem.cs b/backend/Application/Dtos/SimilarCaseItem.cs new file mode 100644 index 0000000..2ab5808 --- /dev/null +++ b/backend/Application/Dtos/SimilarCaseItem.cs @@ -0,0 +1,7 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record SimilarCaseItem( + string Id, + string Title, + double Score); diff --git a/backend/Application/Dtos/SocialChallengeResponse.cs b/backend/Application/Dtos/SocialChallengeResponse.cs new file mode 100644 index 0000000..00b8eba --- /dev/null +++ b/backend/Application/Dtos/SocialChallengeResponse.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record SocialChallengeResponse( + string Id, + string Name, + string Description, + int Order); diff --git a/backend/Application/Dtos/SolutionItem.cs b/backend/Application/Dtos/SolutionItem.cs new file mode 100644 index 0000000..9837a40 --- /dev/null +++ b/backend/Application/Dtos/SolutionItem.cs @@ -0,0 +1,10 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record SolutionItem( + string InnovationId, + string Title, + string Stage, + string ChallengeId, + double Score, + string Rationale); diff --git a/backend/Application/Dtos/StorageInfo.cs b/backend/Application/Dtos/StorageInfo.cs new file mode 100644 index 0000000..0155d40 --- /dev/null +++ b/backend/Application/Dtos/StorageInfo.cs @@ -0,0 +1,8 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record StorageInfo( + string Engine, + string DataDirectory, + string Namespace, + string Database); diff --git a/backend/Application/Dtos/StorageModels.cs b/backend/Application/Dtos/StorageModels.cs deleted file mode 100644 index 9e1e635..0000000 --- a/backend/Application/Dtos/StorageModels.cs +++ /dev/null @@ -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); diff --git a/backend/Application/Dtos/StorageProbe.cs b/backend/Application/Dtos/StorageProbe.cs new file mode 100644 index 0000000..92ef538 --- /dev/null +++ b/backend/Application/Dtos/StorageProbe.cs @@ -0,0 +1,9 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed class StorageProbe +{ + public string Label { get; set; } = "probe"; + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Application/Dtos/StorageProbeResponse.cs b/backend/Application/Dtos/StorageProbeResponse.cs new file mode 100644 index 0000000..6b1de04 --- /dev/null +++ b/backend/Application/Dtos/StorageProbeResponse.cs @@ -0,0 +1,6 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record StorageProbeResponse( + string Label, + DateTimeOffset CreatedAt); diff --git a/backend/Application/Dtos/StorageStatusResponse.cs b/backend/Application/Dtos/StorageStatusResponse.cs new file mode 100644 index 0000000..3ffb53e --- /dev/null +++ b/backend/Application/Dtos/StorageStatusResponse.cs @@ -0,0 +1,9 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record StorageStatusResponse( + string Engine, + string DataDirectory, + string Namespace, + string Database, + int ProbeCount); diff --git a/backend/Application/Dtos/SubmissionDtos.cs b/backend/Application/Dtos/SubmissionResponse.cs similarity index 50% rename from backend/Application/Dtos/SubmissionDtos.cs rename to backend/Application/Dtos/SubmissionResponse.cs index a15d469..23166ce 100644 --- a/backend/Application/Dtos/SubmissionDtos.cs +++ b/backend/Application/Dtos/SubmissionResponse.cs @@ -2,15 +2,6 @@ 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? Keywords); - -public sealed record UpdateSubmissionStatusRequest( - [Required] string Status); public sealed record SubmissionResponse( string Id, diff --git a/backend/Application/Dtos/TestFeedbackResponse.cs b/backend/Application/Dtos/TestFeedbackResponse.cs new file mode 100644 index 0000000..3cf9b05 --- /dev/null +++ b/backend/Application/Dtos/TestFeedbackResponse.cs @@ -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); diff --git a/backend/Application/Dtos/ThreadResponse.cs b/backend/Application/Dtos/ThreadResponse.cs new file mode 100644 index 0000000..a9f60d0 --- /dev/null +++ b/backend/Application/Dtos/ThreadResponse.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record ThreadResponse( + string Id, + string Subject, + IReadOnlyList Participants, + DateTimeOffset CreatedAt, + IReadOnlyList Messages); diff --git a/backend/Application/Dtos/TokenResponse.cs b/backend/Application/Dtos/TokenResponse.cs new file mode 100644 index 0000000..3c5476a --- /dev/null +++ b/backend/Application/Dtos/TokenResponse.cs @@ -0,0 +1,8 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record TokenResponse( + string AccessToken, + string TokenType, + DateTimeOffset ExpiresAt, + UserResponse User); diff --git a/backend/Application/Dtos/TrendPoint.cs b/backend/Application/Dtos/TrendPoint.cs new file mode 100644 index 0000000..1e7a445 --- /dev/null +++ b/backend/Application/Dtos/TrendPoint.cs @@ -0,0 +1,7 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record TrendPoint( + string ChallengeId, + string ChallengeName, + int SubmissionCount); diff --git a/backend/Application/Dtos/TrendsDtos.cs b/backend/Application/Dtos/TrendsResponse.cs similarity index 60% rename from backend/Application/Dtos/TrendsDtos.cs rename to backend/Application/Dtos/TrendsResponse.cs index 093c9a6..03dcfeb 100644 --- a/backend/Application/Dtos/TrendsDtos.cs +++ b/backend/Application/Dtos/TrendsResponse.cs @@ -1,9 +1,5 @@ namespace TarninaHub.Application.Dtos; -public sealed record TrendPoint( - string ChallengeId, - string ChallengeName, - int SubmissionCount); public sealed record TrendsResponse( DateTimeOffset From, diff --git a/backend/Application/Dtos/UpdateApplicationRequest.cs b/backend/Application/Dtos/UpdateApplicationRequest.cs new file mode 100644 index 0000000..c1106e8 --- /dev/null +++ b/backend/Application/Dtos/UpdateApplicationRequest.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record UpdateApplicationRequest( + IReadOnlyDictionary? Content, + string? Status); diff --git a/backend/Application/Dtos/UpdateGrantCallStatusRequest.cs b/backend/Application/Dtos/UpdateGrantCallStatusRequest.cs new file mode 100644 index 0000000..0007d1e --- /dev/null +++ b/backend/Application/Dtos/UpdateGrantCallStatusRequest.cs @@ -0,0 +1,6 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record UpdateGrantCallStatusRequest([Required] string Status); diff --git a/backend/Application/Dtos/UpdateIdeaRequest.cs b/backend/Application/Dtos/UpdateIdeaRequest.cs new file mode 100644 index 0000000..7d4a473 --- /dev/null +++ b/backend/Application/Dtos/UpdateIdeaRequest.cs @@ -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); diff --git a/backend/Application/Dtos/UpdateSubmissionStatusRequest.cs b/backend/Application/Dtos/UpdateSubmissionStatusRequest.cs new file mode 100644 index 0000000..9e7a5f4 --- /dev/null +++ b/backend/Application/Dtos/UpdateSubmissionStatusRequest.cs @@ -0,0 +1,7 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record UpdateSubmissionStatusRequest( + [Required] string Status); diff --git a/backend/Application/Dtos/UserResponse.cs b/backend/Application/Dtos/UserResponse.cs new file mode 100644 index 0000000..ac7c435 --- /dev/null +++ b/backend/Application/Dtos/UserResponse.cs @@ -0,0 +1,9 @@ +namespace TarninaHub.Application.Dtos; + + +public sealed record UserResponse( + string Id, + string Phone, + string? Email, + string Role, + string? OrganizationId); diff --git a/backend/Application/Dtos/VerifyKnowledgeItemRequest.cs b/backend/Application/Dtos/VerifyKnowledgeItemRequest.cs new file mode 100644 index 0000000..7ce2426 --- /dev/null +++ b/backend/Application/Dtos/VerifyKnowledgeItemRequest.cs @@ -0,0 +1,6 @@ +using System.ComponentModel.DataAnnotations; + +namespace TarninaHub.Application.Dtos; + + +public sealed record VerifyKnowledgeItemRequest(bool Verified); diff --git a/backend/Application/Mappings/PartnershipStatusWire.cs b/backend/Application/Mappings/PartnershipStatusWire.cs new file mode 100644 index 0000000..bc044d1 --- /dev/null +++ b/backend/Application/Mappings/PartnershipStatusWire.cs @@ -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, + }; +} diff --git a/backend/Application/Matchmaking/LlmPrompt.cs b/backend/Application/Matchmaking/LlmPrompt.cs new file mode 100644 index 0000000..bbb7401 --- /dev/null +++ b/backend/Application/Matchmaking/LlmPrompt.cs @@ -0,0 +1,4 @@ +namespace TarninaHub.Application.Matchmaking; + + +public sealed record LlmPrompt(string System, string User); diff --git a/backend/Application/Matchmaking/MatchPromptBuilder.cs b/backend/Application/Matchmaking/MatchPromptBuilder.cs index 9303ce5..62fb4d3 100644 --- a/backend/Application/Matchmaking/MatchPromptBuilder.cs +++ b/backend/Application/Matchmaking/MatchPromptBuilder.cs @@ -1,6 +1,5 @@ namespace TarninaHub.Application.Matchmaking; -public sealed record LlmPrompt(string System, string User); public static class MatchPromptBuilder { diff --git a/backend/Application/Matchmaking/RankedItem.cs b/backend/Application/Matchmaking/RankedItem.cs new file mode 100644 index 0000000..0f10068 --- /dev/null +++ b/backend/Application/Matchmaking/RankedItem.cs @@ -0,0 +1,4 @@ +namespace TarninaHub.Application.Matchmaking; + + +public sealed record RankedItem(T Item, double Score); diff --git a/backend/Application/Matchmaking/SemanticRanker.cs b/backend/Application/Matchmaking/SemanticRanker.cs index d2278e8..a57b19f 100644 --- a/backend/Application/Matchmaking/SemanticRanker.cs +++ b/backend/Application/Matchmaking/SemanticRanker.cs @@ -1,6 +1,5 @@ namespace TarninaHub.Application.Matchmaking; -public sealed record RankedItem(T Item, double Score); public static class SemanticRanker { diff --git a/backend/Application/Security/ConsentScopes.cs b/backend/Application/Security/ConsentScopes.cs new file mode 100644 index 0000000..6fce33d --- /dev/null +++ b/backend/Application/Security/ConsentScopes.cs @@ -0,0 +1,16 @@ +namespace TarninaHub.Application.Security; + + +public static class ConsentScopes +{ + /// Access granted by the data subject's consent. + public const string ByConsent = "by-consent"; + + /// Time-limited access (uses consent.expires_at). + public const string Temporary = "temporary"; + + /// Fully anonymized data (no personal identifiers). + public const string Anonymized = "anonymized"; + + public static readonly IReadOnlyList All = [ByConsent, Temporary, Anonymized]; +} diff --git a/backend/Application/Security/Policies.cs b/backend/Application/Security/Policies.cs new file mode 100644 index 0000000..bca6084 --- /dev/null +++ b/backend/Application/Security/Policies.cs @@ -0,0 +1,20 @@ +namespace TarninaHub.Application.Security; + + +public static class Policies +{ + /// operator, manager, admin — ROPS/expert staff. + public const string Staff = "Staff"; + + /// operator, admin. + public const string OperatorOrAdmin = "OperatorOrAdmin"; + + /// manager, operator, admin. + public const string ManagerOrAbove = "ManagerOrAbove"; + + /// admin only. + public const string AdminOnly = "AdminOnly"; + + /// jst and above (jst, operator, manager, admin). + public const string JstOrAbove = "JstOrAbove"; +} diff --git a/backend/Application/Security/RoleNames.cs b/backend/Application/Security/RoleNames.cs new file mode 100644 index 0000000..add0919 --- /dev/null +++ b/backend/Application/Security/RoleNames.cs @@ -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 All = + [User, Jst, Operator, Manager, Admin]; + + public static bool IsValid(string role) => All.Contains(role); +} diff --git a/backend/Application/Security/SecurityConstants.cs b/backend/Application/Security/SecurityConstants.cs deleted file mode 100644 index 806d771..0000000 --- a/backend/Application/Security/SecurityConstants.cs +++ /dev/null @@ -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 All = - [User, Jst, Operator, Manager, Admin]; - - public static bool IsValid(string role) => All.Contains(role); -} - -public static class Policies -{ - /// operator, manager, admin — ROPS/expert staff. - public const string Staff = "Staff"; - - /// operator, admin. - public const string OperatorOrAdmin = "OperatorOrAdmin"; - - /// manager, operator, admin. - public const string ManagerOrAbove = "ManagerOrAbove"; - - /// admin only. - public const string AdminOnly = "AdminOnly"; - - /// jst and above (jst, operator, manager, admin). - public const string JstOrAbove = "JstOrAbove"; -} - -public static class ConsentScopes -{ - /// Access granted by the data subject's consent. - public const string ByConsent = "by-consent"; - - /// Time-limited access (uses consent.expires_at). - public const string Temporary = "temporary"; - - /// Fully anonymized data (no personal identifiers). - public const string Anonymized = "anonymized"; - - public static readonly IReadOnlyList All = [ByConsent, Temporary, Anonymized]; -} diff --git a/backend/Controllers/AdminController.cs b/backend/Controllers/AdminController.cs new file mode 100644 index 0000000..5396184 --- /dev/null +++ b/backend/Controllers/AdminController.cs @@ -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>> 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> 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> Metrics(CancellationToken cancellationToken) + { + var metrics = await _admin.GetMetricsAsync(cancellationToken); + + return Ok(metrics); + } + + [HttpPut("grant-calls/{grantCallId}/status")] + public async Task> 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 + { + ["grantCallId"] = call.Id, + ["name"] = call.Name, + }; + + foreach (var authorId in authorIds) + { + await _notifications.NotifyUserAsync(authorId, "grant_call.opened", payload, cancellationToken); + } + } +} diff --git a/backend/Controllers/ConsentsController.cs b/backend/Controllers/ConsentsController.cs index e7664b3..820bce2 100644 --- a/backend/Controllers/ConsentsController.cs +++ b/backend/Controllers/ConsentsController.cs @@ -48,7 +48,7 @@ public class ConsentsController : ControllerBase userId, "consent.grant", consent.Id, - new Dictionary { ["scope"] = request.Scope }, + new Dictionary { ["scope"] = request.Scope }, cancellationToken); return StatusCode(StatusCodes.Status201Created, consent); @@ -90,7 +90,7 @@ public class ConsentsController : ControllerBase userId, "consent.revoke", consentId, - new Dictionary(), + new Dictionary(), cancellationToken); return NoContent(); diff --git a/backend/Controllers/HealthController.cs b/backend/Controllers/HealthController.cs index c395a5e..74c2b2e 100644 --- a/backend/Controllers/HealthController.cs +++ b/backend/Controllers/HealthController.cs @@ -4,6 +4,7 @@ namespace TarninaHub.Controllers; [ApiController] [Route("api/health")] + public class HealthController : ControllerBase { [HttpGet] @@ -12,5 +13,3 @@ public class HealthController : ControllerBase return Ok(new HealthResponse("ok")); } } - -public record HealthResponse(string Status); diff --git a/backend/Controllers/HealthResponse.cs b/backend/Controllers/HealthResponse.cs new file mode 100644 index 0000000..a2b73ad --- /dev/null +++ b/backend/Controllers/HealthResponse.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc; + +namespace TarninaHub.Controllers; + +[ApiController] +[Route("api/health")] + +public record HealthResponse(string Status); diff --git a/backend/Controllers/IdeasController.cs b/backend/Controllers/IdeasController.cs index dc1007d..0541dae 100644 --- a/backend/Controllers/IdeasController.cs +++ b/backend/Controllers/IdeasController.cs @@ -17,11 +17,16 @@ public class IdeasController : ControllerBase { private readonly IIdeaService _ideaService; private readonly IIdeaAssistant _ideaAssistant; + private readonly INotificationService _notifications; - public IdeasController(IIdeaService ideaService, IIdeaAssistant ideaAssistant) + public IdeasController( + IIdeaService ideaService, + IIdeaAssistant ideaAssistant, + INotificationService notifications) { _ideaService = ideaService; _ideaAssistant = ideaAssistant; + _notifications = notifications; } [HttpPost] @@ -38,6 +43,16 @@ public class IdeasController : ControllerBase var created = await _ideaService.CreateAsync(userId, request, cancellationToken); + await _notifications.NotifyStaffAsync( + "idea.created", + new Dictionary + { + ["ideaId"] = created.Id, + ["title"] = created.Title, + ["authorId"] = created.AuthorId, + }, + cancellationToken); + return CreatedAtAction(nameof(GetById), new { ideaId = created.Id }, created); } diff --git a/backend/Controllers/InnovationsController.cs b/backend/Controllers/InnovationsController.cs index 41c7b6b..fe4e192 100644 --- a/backend/Controllers/InnovationsController.cs +++ b/backend/Controllers/InnovationsController.cs @@ -16,11 +16,16 @@ public class InnovationsController : ControllerBase { private readonly IInnovationService _innovations; private readonly IMatchmakingService _matchmakingService; + private readonly ITestFeedbackService _testFeedback; - public InnovationsController(IInnovationService innovations, IMatchmakingService matchmakingService) + public InnovationsController( + IInnovationService innovations, + IMatchmakingService matchmakingService, + ITestFeedbackService testFeedback) { _innovations = innovations; _matchmakingService = matchmakingService; + _testFeedback = testFeedback; } [HttpGet] @@ -85,6 +90,35 @@ public class InnovationsController : ControllerBase return updated is null ? NotFound() : Ok(updated); } + [HttpPost("{innovationId}/test-feedback")] + public async Task> 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>> ListTestFeedback( + string innovationId, + CancellationToken cancellationToken) + { + var feedback = await _testFeedback.ListForInnovationAsync(innovationId, cancellationToken); + + return Ok(feedback); + } + private bool ValidateStage(string? stage) => InnovationStageWire.IsValid(stage); private ActionResult InvalidStage() diff --git a/backend/Controllers/MatchmakingController.cs b/backend/Controllers/MatchmakingController.cs index 358699d..5a11cae 100644 --- a/backend/Controllers/MatchmakingController.cs +++ b/backend/Controllers/MatchmakingController.cs @@ -10,6 +10,7 @@ namespace TarninaHub.Controllers; [ApiVersion("1.0")] [Route("api/matchmaking")] [Authorize] + public class MatchmakingController : ControllerBase { private readonly IMatchmakingService _matchmakingService; @@ -28,5 +29,3 @@ public class MatchmakingController : ControllerBase return Ok(new MatchmakingReindexResponse(indexed)); } } - -public sealed record MatchmakingReindexResponse(int Indexed); diff --git a/backend/Controllers/MatchmakingReindexResponse.cs b/backend/Controllers/MatchmakingReindexResponse.cs new file mode 100644 index 0000000..ae9f0c8 --- /dev/null +++ b/backend/Controllers/MatchmakingReindexResponse.cs @@ -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); diff --git a/backend/Controllers/MiddlemanController.cs b/backend/Controllers/MiddlemanController.cs new file mode 100644 index 0000000..e6da3a8 --- /dev/null +++ b/backend/Controllers/MiddlemanController.cs @@ -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> Adapt( + MiddlemanRequest request, + CancellationToken cancellationToken) + { + var response = await _middleman.AdaptAsync(request, cancellationToken); + + return response is null ? NotFound() : Ok(response); + } +} diff --git a/backend/Controllers/NotificationsController.cs b/backend/Controllers/NotificationsController.cs new file mode 100644 index 0000000..470ac3a --- /dev/null +++ b/backend/Controllers/NotificationsController.cs @@ -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>> 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> 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); + } +} diff --git a/backend/Controllers/PartnershipsController.cs b/backend/Controllers/PartnershipsController.cs new file mode 100644 index 0000000..afe1390 --- /dev/null +++ b/backend/Controllers/PartnershipsController.cs @@ -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>> 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> 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); +} diff --git a/backend/Controllers/SubmissionsController.cs b/backend/Controllers/SubmissionsController.cs index a0063b4..d57d47f 100644 --- a/backend/Controllers/SubmissionsController.cs +++ b/backend/Controllers/SubmissionsController.cs @@ -17,13 +17,16 @@ public class SubmissionsController : ControllerBase { private readonly ISubmissionService _submissionService; private readonly IMatchmakingService _matchmakingService; + private readonly INotificationService _notifications; public SubmissionsController( ISubmissionService submissionService, - IMatchmakingService matchmakingService) + IMatchmakingService matchmakingService, + INotificationService notifications) { _submissionService = submissionService; _matchmakingService = matchmakingService; + _notifications = notifications; } [HttpPost] @@ -42,6 +45,16 @@ public class SubmissionsController : ControllerBase await _matchmakingService.IndexSubmissionAsync(created.Id, cancellationToken); + await _notifications.NotifyStaffAsync( + "submission.created", + new Dictionary + { + ["submissionId"] = created.Id, + ["title"] = created.Title, + ["authorId"] = created.AuthorId, + }, + cancellationToken); + return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created); } diff --git a/backend/Controllers/ThreadsController.cs b/backend/Controllers/ThreadsController.cs new file mode 100644 index 0000000..ec3658f --- /dev/null +++ b/backend/Controllers/ThreadsController.cs @@ -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>> 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> 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> 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> 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); +} diff --git a/backend/Controllers/UsersController.cs b/backend/Controllers/UsersController.cs index 2b62e49..813d59f 100644 --- a/backend/Controllers/UsersController.cs +++ b/backend/Controllers/UsersController.cs @@ -64,7 +64,7 @@ public class UsersController : ControllerBase currentUserId, "user.read", userId, - new Dictionary { ["reason"] = "staff-lookup" }, + new Dictionary { ["reason"] = "staff-lookup" }, cancellationToken); } diff --git a/backend/Domain/Rules/PartnershipWorkflow.cs b/backend/Domain/Rules/PartnershipWorkflow.cs new file mode 100644 index 0000000..68a93ab --- /dev/null +++ b/backend/Domain/Rules/PartnershipWorkflow.cs @@ -0,0 +1,20 @@ +using TarninaHub.Domain.Enums; + +namespace TarninaHub.Domain.Rules; + +public static class PartnershipWorkflow +{ + private static readonly IReadOnlyDictionary Transitions = + new Dictionary + { + [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; +} diff --git a/backend/Infrastructure/Assist/MiddlemanAssistant.cs b/backend/Infrastructure/Assist/MiddlemanAssistant.cs new file mode 100644 index 0000000..d279ed8 --- /dev/null +++ b/backend/Infrastructure/Assist/MiddlemanAssistant.cs @@ -0,0 +1,113 @@ +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Matchmaking; + +namespace TarninaHub.Infrastructure.Assist; + +/// +/// 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. +/// +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 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 BuildResources(InnovationResponse innovation) + { + var resources = new List + { + "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; + } +} diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index fbb736c..b6d38bd 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -85,6 +85,12 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/backend/Infrastructure/Persistence/AdminService.cs b/backend/Infrastructure/Persistence/AdminService.cs new file mode 100644 index 0000000..ce8cd19 --- /dev/null +++ b/backend/Infrastructure/Persistence/AdminService.cs @@ -0,0 +1,96 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class AdminService : IAdminService +{ + private const int MaxSubmissions = 200; + + private readonly ISurrealDbClient _client; + private readonly ISubmissionService _submissions; + private readonly IKnowledgeItemService _knowledgeItems; + + public AdminService( + ISurrealDbClient client, + ISubmissionService submissions, + IKnowledgeItemService knowledgeItems) + { + _client = client; + _submissions = submissions; + _knowledgeItems = knowledgeItems; + } + + public async Task> ListSubmissionsAsync( + string? status, + string? challengeId, + CancellationToken cancellationToken) + { + var page = await _submissions.ListAsync( + viewerId: string.Empty, + isStaff: true, + status, + page: 1, + pageSize: MaxSubmissions, + cancellationToken); + + return string.IsNullOrWhiteSpace(challengeId) + ? page.Items + : page.Items.Where(item => item.ChallengeId == challengeId).ToList(); + } + + public async Task VerifyKnowledgeItemAsync( + string knowledgeItemId, + VerifyKnowledgeItemRequest request, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(knowledgeItemId); + + var response = await _client.RawQuery( + "UPDATE type::record($table, $id) SET published = $published, updated_at = time::now();", + new Dictionary + { + ["table"] = table, + ["id"] = id, + ["published"] = request.Verified, + }, + cancellationToken); + response.EnsureAllOks(); + + return await _knowledgeItems.GetAsync(knowledgeItemId, cancellationToken); + } + + public async Task GetMetricsAsync(CancellationToken cancellationToken) + { + var response = await _client.RawQuery( + "SELECT count() AS count FROM submission GROUP ALL; " + + "SELECT count() AS count FROM problem GROUP ALL; " + + "SELECT count() AS count FROM idea GROUP ALL; " + + "SELECT count() AS count FROM innovation GROUP ALL; " + + "SELECT count() AS count FROM knowledge_item GROUP ALL; " + + "SELECT count() AS count FROM user GROUP ALL; " + + "SELECT count() AS count FROM grant_call WHERE status = 'open' GROUP ALL; " + + "SELECT count() AS count FROM application GROUP ALL;", + new Dictionary(), + cancellationToken); + response.EnsureAllOks(); + + return new AdminMetricsResponse( + Count(response, 0), + Count(response, 1), + Count(response, 2), + Count(response, 3), + Count(response, 4), + Count(response, 5), + Count(response, 6), + Count(response, 7)); + } + + private static int Count(SurrealDb.Net.Models.Response.SurrealDbResponse response, int index) + { + var rows = response.GetValue>(index) ?? []; + + return rows.Count > 0 ? rows[0].Count : 0; + } +} diff --git a/backend/Infrastructure/Persistence/ApplicationRow.cs b/backend/Infrastructure/Persistence/ApplicationRow.cs new file mode 100644 index 0000000..66e57af --- /dev/null +++ b/backend/Infrastructure/Persistence/ApplicationRow.cs @@ -0,0 +1,19 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +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; } +} diff --git a/backend/Infrastructure/Persistence/ApplicationService.cs b/backend/Infrastructure/Persistence/ApplicationService.cs index de808ae..e64e92e 100644 --- a/backend/Infrastructure/Persistence/ApplicationService.cs +++ b/backend/Infrastructure/Persistence/ApplicationService.cs @@ -66,7 +66,7 @@ public sealed class ApplicationService : IApplicationService ["grantId"] = grantId, ["ideaTable"] = ideaTable, ["ideaId"] = ideaIdPart, - ["content"] = ToStorageContent(request.Content), + ["content"] = request.Content ?? new Dictionary(), }, cancellationToken); response.EnsureAllOks(); @@ -113,24 +113,7 @@ public sealed class ApplicationService : IApplicationService row.Id, row.GrantCall, row.Idea, - row.Content.ToDictionary(pair => pair.Key, pair => (object?)pair.Value), + row.Content, 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/AuditLogService.cs b/backend/Infrastructure/Persistence/AuditLogService.cs index 1a6b487..ddd6480 100644 --- a/backend/Infrastructure/Persistence/AuditLogService.cs +++ b/backend/Infrastructure/Persistence/AuditLogService.cs @@ -16,7 +16,7 @@ public sealed class AuditLogService : IAuditLogService string? actorId, string action, string target, - IReadOnlyDictionary context, + IReadOnlyDictionary context, CancellationToken cancellationToken) { var (actorTable, actorIdPart) = actorId is null diff --git a/backend/Infrastructure/Persistence/ChallengeRow.cs b/backend/Infrastructure/Persistence/ChallengeRow.cs new file mode 100644 index 0000000..522abc6 --- /dev/null +++ b/backend/Infrastructure/Persistence/ChallengeRow.cs @@ -0,0 +1,15 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class ChallengeRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("name")] public string Name { get; set; } = string.Empty; + + [CborProperty("description")] public string Description { get; set; } = string.Empty; + + [CborProperty("order")] public int Order { get; set; } +} diff --git a/backend/Infrastructure/Persistence/ConsentRow.cs b/backend/Infrastructure/Persistence/ConsentRow.cs new file mode 100644 index 0000000..560bac8 --- /dev/null +++ b/backend/Infrastructure/Persistence/ConsentRow.cs @@ -0,0 +1,19 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class ConsentRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("user")] public string User { get; set; } = string.Empty; + + [CborProperty("scope")] public string Scope { get; set; } = string.Empty; + + [CborProperty("granted_at")] public DateTimeOffset GrantedAt { get; set; } + + [CborProperty("expires_at")] public DateTimeOffset? ExpiresAt { get; set; } + + [CborProperty("revoked_at")] public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/backend/Infrastructure/Persistence/CountRow.cs b/backend/Infrastructure/Persistence/CountRow.cs new file mode 100644 index 0000000..f185199 --- /dev/null +++ b/backend/Infrastructure/Persistence/CountRow.cs @@ -0,0 +1,9 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class CountRow +{ + [CborProperty("count")] public int Count { get; set; } +} diff --git a/backend/Infrastructure/Persistence/GrantCallRow.cs b/backend/Infrastructure/Persistence/GrantCallRow.cs new file mode 100644 index 0000000..3b54582 --- /dev/null +++ b/backend/Infrastructure/Persistence/GrantCallRow.cs @@ -0,0 +1,19 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +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; } +} diff --git a/backend/Infrastructure/Persistence/GrantCallService.cs b/backend/Infrastructure/Persistence/GrantCallService.cs index 3d85f8e..faccae9 100644 --- a/backend/Infrastructure/Persistence/GrantCallService.cs +++ b/backend/Infrastructure/Persistence/GrantCallService.cs @@ -55,6 +55,30 @@ public sealed class GrantCallService : IGrantCallService return rows.Count > 0 ? Map(rows[0]) : null; } + public async Task UpdateStatusAsync( + string grantCallId, + string status, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(grantCallId); + + var response = await _client.RawQuery( + "UPDATE type::record($table, $id) SET status = $status; " + + $"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;", + new Dictionary + { + ["table"] = table, + ["id"] = id, + ["status"] = status, + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + private static GrantCallResponse Map(GrantCallRow row) => new( row.Id, row.Name, diff --git a/backend/Infrastructure/Persistence/IdRow.cs b/backend/Infrastructure/Persistence/IdRow.cs new file mode 100644 index 0000000..52b32ec --- /dev/null +++ b/backend/Infrastructure/Persistence/IdRow.cs @@ -0,0 +1,9 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class IdRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; +} diff --git a/backend/Infrastructure/Persistence/IdeaCardRow.cs b/backend/Infrastructure/Persistence/IdeaCardRow.cs new file mode 100644 index 0000000..b5b6d6e --- /dev/null +++ b/backend/Infrastructure/Persistence/IdeaCardRow.cs @@ -0,0 +1,17 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +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; +} diff --git a/backend/Infrastructure/Persistence/IdeaRow.cs b/backend/Infrastructure/Persistence/IdeaRow.cs new file mode 100644 index 0000000..7ea7922 --- /dev/null +++ b/backend/Infrastructure/Persistence/IdeaRow.cs @@ -0,0 +1,19 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +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; } +} diff --git a/backend/Infrastructure/Persistence/IdeaService.cs b/backend/Infrastructure/Persistence/IdeaService.cs index 2f7ce5e..a361f6c 100644 --- a/backend/Infrastructure/Persistence/IdeaService.cs +++ b/backend/Infrastructure/Persistence/IdeaService.cs @@ -93,6 +93,19 @@ public sealed class IdeaService : IIdeaService return result; } + public async Task> ListDistinctAuthorIdsAsync(CancellationToken cancellationToken) + { + var response = await _client.RawQuery( + "SELECT type::string(author) AS id FROM idea;", + new Dictionary(), + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Select(row => row.Id).Distinct(StringComparer.Ordinal).ToList(); + } + public async Task UpdateAsync( string ideaId, UpdateIdeaRequest request, diff --git a/backend/Infrastructure/Persistence/InnovationRow.cs b/backend/Infrastructure/Persistence/InnovationRow.cs new file mode 100644 index 0000000..9264e10 --- /dev/null +++ b/backend/Infrastructure/Persistence/InnovationRow.cs @@ -0,0 +1,31 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class InnovationRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("title")] public string Title { get; set; } = string.Empty; + + [CborProperty("summary")] public string Summary { get; set; } = string.Empty; + + [CborProperty("description")] public string Description { get; set; } = string.Empty; + + [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; + + [CborProperty("stage")] public string Stage { get; set; } = string.Empty; + + [CborProperty("cost")] public string? Cost { get; set; } + + [CborProperty("contact")] public Dictionary? Contact { get; set; } + + [CborProperty("media")] public List> Media { get; set; } = []; + + [CborProperty("published")] public bool Published { get; set; } + + [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } + + [CborProperty("embedding")] public List? Embedding { get; set; } +} diff --git a/backend/Infrastructure/Persistence/InnovationService.cs b/backend/Infrastructure/Persistence/InnovationService.cs index 989921f..3343bc7 100644 --- a/backend/Infrastructure/Persistence/InnovationService.cs +++ b/backend/Infrastructure/Persistence/InnovationService.cs @@ -68,7 +68,7 @@ public sealed class InnovationService : IInnovationService ["challengeTable"] = challengeTable, ["challengeId"] = challengeId, ["stage"] = request.Stage, - ["media"] = request.Media ?? [], + ["media"] = ToConcreteList(request.Media), ["published"] = request.Published, }; @@ -112,7 +112,7 @@ public sealed class InnovationService : IInnovationService ["challengeTable"] = challengeTable, ["challengeId"] = challengeId, ["stage"] = request.Stage, - ["media"] = request.Media ?? [], + ["media"] = ToConcreteList(request.Media), ["published"] = request.Published, }; @@ -200,13 +200,13 @@ public sealed class InnovationService : IInnovationService List assignments, Dictionary parameters, string field, - IReadOnlyDictionary? value, + IReadOnlyDictionary? value, bool clearWhenMissing = false) { if (value is not null) { assignments.Add($"{field} = $field_{field}"); - parameters[$"field_{field}"] = value; + parameters[$"field_{field}"] = value.ToDictionary(pair => pair.Key, pair => pair.Value); } else if (clearWhenMissing) { @@ -214,6 +214,10 @@ public sealed class InnovationService : IInnovationService } } + private static List> ToConcreteList( + IReadOnlyList>? values) => + values?.Select(item => item.ToDictionary(pair => pair.Key, pair => pair.Value)).ToList() ?? []; + private static InnovationResponse Map(InnovationRow row) => new( row.Id, row.Title, @@ -222,8 +226,8 @@ public sealed class InnovationService : IInnovationService row.Challenge, row.Stage, row.Cost, - row.Contact is null ? null : row.Contact, - row.Media.Select(item => (IReadOnlyDictionary)item).ToList(), + row.Contact, + row.Media.Select(item => (IReadOnlyDictionary)item).ToList(), row.Published, row.UpdatedAt); } diff --git a/backend/Infrastructure/Persistence/KnowledgeItemRow.cs b/backend/Infrastructure/Persistence/KnowledgeItemRow.cs new file mode 100644 index 0000000..1aabcd2 --- /dev/null +++ b/backend/Infrastructure/Persistence/KnowledgeItemRow.cs @@ -0,0 +1,23 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class KnowledgeItemRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("title")] public string Title { get; set; } = string.Empty; + + [CborProperty("kind")] public string Kind { get; set; } = string.Empty; + + [CborProperty("challenge")] public string? Challenge { get; set; } + + [CborProperty("source_url")] public string? SourceUrl { get; set; } + + [CborProperty("attachments")] public List> Attachments { get; set; } = []; + + [CborProperty("published")] public bool Published { get; set; } + + [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } +} diff --git a/backend/Infrastructure/Persistence/KnowledgeItemService.cs b/backend/Infrastructure/Persistence/KnowledgeItemService.cs index b0d917b..eb1c2ba 100644 --- a/backend/Infrastructure/Persistence/KnowledgeItemService.cs +++ b/backend/Infrastructure/Persistence/KnowledgeItemService.cs @@ -62,7 +62,7 @@ public sealed class KnowledgeItemService : IKnowledgeItemService ["id"] = id, ["title"] = request.Title, ["kind"] = request.Kind, - ["attachments"] = request.Attachments ?? [], + ["attachments"] = ToConcreteList(request.Attachments), ["published"] = request.Published, }; @@ -99,7 +99,7 @@ public sealed class KnowledgeItemService : IKnowledgeItemService ["id"] = id, ["title"] = request.Title, ["kind"] = request.Kind, - ["attachments"] = request.Attachments ?? [], + ["attachments"] = ToConcreteList(request.Attachments), ["published"] = request.Published, }; @@ -216,13 +216,17 @@ public sealed class KnowledgeItemService : IKnowledgeItemService } } + private static List> ToConcreteList( + IReadOnlyList>? values) => + values?.Select(item => item.ToDictionary(pair => pair.Key, pair => pair.Value)).ToList() ?? []; + private static KnowledgeItemResponse Map(KnowledgeItemRow row) => new( row.Id, row.Title, row.Kind, NormalizeRecordId(row.Challenge), row.SourceUrl, - row.Attachments.Select(item => (IReadOnlyDictionary)item).ToList(), + row.Attachments.Select(item => (IReadOnlyDictionary)item).ToList(), row.Published, row.UpdatedAt); diff --git a/backend/Infrastructure/Persistence/MessageRow.cs b/backend/Infrastructure/Persistence/MessageRow.cs new file mode 100644 index 0000000..9f0a535 --- /dev/null +++ b/backend/Infrastructure/Persistence/MessageRow.cs @@ -0,0 +1,17 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class MessageRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("thread")] public string Thread { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; + + [CborProperty("body")] public string Body { get; set; } = string.Empty; + + [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Infrastructure/Persistence/NotificationRow.cs b/backend/Infrastructure/Persistence/NotificationRow.cs new file mode 100644 index 0000000..0a8b103 --- /dev/null +++ b/backend/Infrastructure/Persistence/NotificationRow.cs @@ -0,0 +1,17 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class NotificationRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("type")] public string Type { get; set; } = string.Empty; + + [CborProperty("payload")] public Dictionary Payload { get; set; } = []; + + [CborProperty("read_at")] public DateTimeOffset? ReadAt { get; set; } + + [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Infrastructure/Persistence/NotificationService.cs b/backend/Infrastructure/Persistence/NotificationService.cs new file mode 100644 index 0000000..27c466e --- /dev/null +++ b/backend/Infrastructure/Persistence/NotificationService.cs @@ -0,0 +1,115 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class NotificationService : INotificationService +{ + private const string SelectFields = + "type::string(id) AS id, type, payload, read_at, created_at"; + + private static readonly string[] StaffRoles = ["operator", "manager", "admin"]; + + private readonly ISurrealDbClient _client; + + public NotificationService(ISurrealDbClient client) + { + _client = client; + } + + public async Task NotifyStaffAsync( + string type, + IReadOnlyDictionary payload, + CancellationToken cancellationToken) + { + var response = await _client.RawQuery( + "SELECT type::string(id) AS id FROM user WHERE role IN $roles;", + new Dictionary { ["roles"] = StaffRoles }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + foreach (var row in rows) + { + await NotifyUserAsync(row.Id, type, payload, cancellationToken); + } + } + + public async Task NotifyUserAsync( + string userId, + string type, + IReadOnlyDictionary payload, + CancellationToken cancellationToken) + { + var notificationId = Guid.NewGuid().ToString("N"); + var (table, id) = SurrealRecordId.Split(userId); + + var response = await _client.RawQuery( + "CREATE type::record('notification', $id) SET recipient = type::record($table, $recipientId), " + + "type = $type, payload = $payload;", + new Dictionary + { + ["id"] = notificationId, + ["table"] = table, + ["recipientId"] = id, + ["type"] = type, + ["payload"] = payload, + }, + cancellationToken); + response.EnsureAllOks(); + } + + public async Task> ListForUserAsync( + string userId, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(userId); + + var response = await _client.RawQuery( + $"SELECT {SelectFields} FROM notification WHERE recipient = 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(); + } + + public async Task MarkReadAsync( + string notificationId, + string userId, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(notificationId); + var (userTable, userIdPart) = SurrealRecordId.Split(userId); + + var response = await _client.RawQuery( + "UPDATE type::record($table, $id) SET read_at = time::now() " + + "WHERE recipient = type::record($userTable, $userId); " + + $"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;", + new Dictionary + { + ["table"] = table, + ["id"] = id, + ["userTable"] = userTable, + ["userId"] = userIdPart, + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + private static NotificationResponse Map(NotificationRow row) => new( + row.Id, + row.Type, + row.Payload, + row.ReadAt, + row.CreatedAt); +} diff --git a/backend/Infrastructure/Persistence/PartnershipRow.cs b/backend/Infrastructure/Persistence/PartnershipRow.cs new file mode 100644 index 0000000..ebd128f --- /dev/null +++ b/backend/Infrastructure/Persistence/PartnershipRow.cs @@ -0,0 +1,17 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class PartnershipRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("initiator")] public string Initiator { get; set; } = string.Empty; + + [CborProperty("subject")] public string Subject { get; set; } = string.Empty; + + [CborProperty("partners")] public List Partners { get; set; } = []; + + [CborProperty("status")] public string Status { get; set; } = string.Empty; +} diff --git a/backend/Infrastructure/Persistence/PartnershipService.cs b/backend/Infrastructure/Persistence/PartnershipService.cs new file mode 100644 index 0000000..9439da9 --- /dev/null +++ b/backend/Infrastructure/Persistence/PartnershipService.cs @@ -0,0 +1,95 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class PartnershipService : IPartnershipService +{ + private const string SelectFields = + "type::string(id) AS id, type::string(initiator) AS initiator, subject, " + + "array::map(partners, |$p| type::string($p)) AS partners, status"; + + private readonly ISurrealDbClient _client; + + public PartnershipService(ISurrealDbClient client) + { + _client = client; + } + + public async Task> ListForUserAsync( + string userId, + bool isStaff, + CancellationToken cancellationToken) + { + var parameters = new Dictionary(); + var where = string.Empty; + + if (!isStaff) + { + var (table, id) = SurrealRecordId.Split(userId); + where = " WHERE initiator = type::record($table, $id)"; + parameters["table"] = table; + parameters["id"] = id; + } + + var response = await _client.RawQuery( + $"SELECT {SelectFields} FROM partnership{where} ORDER BY subject ASC;", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Select(Map).ToList(); + } + + public async Task CreateAsync( + string initiatorId, + CreatePartnershipRequest request, + CancellationToken cancellationToken) + { + var partnershipId = Guid.NewGuid().ToString("N"); + var (initiatorTable, initiatorIdPart) = SurrealRecordId.Split(initiatorId); + var partners = request.Partners ?? []; + var parameters = new Dictionary + { + ["id"] = partnershipId, + ["subject"] = request.Subject, + ["initiatorTable"] = initiatorTable, + ["initiatorId"] = initiatorIdPart, + }; + + var partnerExpressions = new List(partners.Count); + + for (var i = 0; i < partners.Count; i++) + { + var (table, id) = SurrealRecordId.Split(partners[i]); + parameters[$"table{i}"] = table; + parameters[$"id{i}"] = id; + partnerExpressions.Add($"type::record($table{i}, $id{i})"); + } + + var response = await _client.RawQuery( + "CREATE type::record('partnership', $id) SET " + + "initiator = type::record($initiatorTable, $initiatorId), subject = $subject, " + + $"partners = [{string.Join(", ", partnerExpressions)}], status = 'proposed'; " + + $"SELECT {SelectFields} FROM type::record('partnership', $id) LIMIT 1;", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 + ? Map(rows[0]) + : throw new InvalidOperationException("Partnership could not be loaded after creation."); + } + + private static PartnershipResponse Map(PartnershipRow row) => new( + row.Id, + row.Initiator, + row.Subject, + row.Partners, + row.Status); +} diff --git a/backend/Infrastructure/Persistence/PasswordCheckRow.cs b/backend/Infrastructure/Persistence/PasswordCheckRow.cs new file mode 100644 index 0000000..730d048 --- /dev/null +++ b/backend/Infrastructure/Persistence/PasswordCheckRow.cs @@ -0,0 +1,9 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class PasswordCheckRow +{ + [CborProperty("password_ok")] public bool PasswordOk { get; set; } +} diff --git a/backend/Infrastructure/Persistence/ProblemRow.cs b/backend/Infrastructure/Persistence/ProblemRow.cs new file mode 100644 index 0000000..a5707ce --- /dev/null +++ b/backend/Infrastructure/Persistence/ProblemRow.cs @@ -0,0 +1,21 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class ProblemRow +{ + [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("challenge")] public string Challenge { get; set; } = string.Empty; + + [CborProperty("location")] public string Location { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; + + [CborProperty("embedding")] public List? Embedding { get; set; } +} diff --git a/backend/Infrastructure/Persistence/SubmissionRow.cs b/backend/Infrastructure/Persistence/SubmissionRow.cs new file mode 100644 index 0000000..11b3252 --- /dev/null +++ b/backend/Infrastructure/Persistence/SubmissionRow.cs @@ -0,0 +1,29 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class SubmissionRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("problem")] public string Problem { get; set; } = string.Empty; + + [CborProperty("status")] public string Status { get; set; } = string.Empty; + + [CborProperty("keywords")] public List Keywords { get; set; } = []; + + [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } + + [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } + + [CborProperty("title")] public string Title { get; set; } = string.Empty; + + [CborProperty("description")] public string Description { get; set; } = string.Empty; + + [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; + + [CborProperty("location")] public string Location { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; +} diff --git a/backend/Infrastructure/Persistence/SurrealRows.cs b/backend/Infrastructure/Persistence/SurrealRows.cs deleted file mode 100644 index cc20393..0000000 --- a/backend/Infrastructure/Persistence/SurrealRows.cs +++ /dev/null @@ -1,215 +0,0 @@ -using Dahomey.Cbor.Attributes; - -namespace TarninaHub.Infrastructure.Persistence; - -internal sealed class UserRow -{ - [CborProperty("id")] public string Id { get; set; } = string.Empty; - - [CborProperty("phone")] public string Phone { get; set; } = string.Empty; - - [CborProperty("email")] public string? Email { get; set; } - - [CborProperty("role")] public string Role { get; set; } = string.Empty; - - [CborProperty("organization")] public string? Organization { get; set; } -} - -internal sealed class PasswordCheckRow -{ - [CborProperty("password_ok")] public bool PasswordOk { get; set; } -} - -internal sealed class ConsentRow -{ - [CborProperty("id")] public string Id { get; set; } = string.Empty; - - [CborProperty("user")] public string User { get; set; } = string.Empty; - - [CborProperty("scope")] public string Scope { get; set; } = string.Empty; - - [CborProperty("granted_at")] public DateTimeOffset GrantedAt { get; set; } - - [CborProperty("expires_at")] public DateTimeOffset? ExpiresAt { get; set; } - - [CborProperty("revoked_at")] public DateTimeOffset? RevokedAt { get; set; } -} - -internal sealed class SubmissionRow -{ - [CborProperty("id")] public string Id { get; set; } = string.Empty; - - [CborProperty("problem")] public string Problem { get; set; } = string.Empty; - - [CborProperty("status")] public string Status { get; set; } = string.Empty; - - [CborProperty("keywords")] public List Keywords { get; set; } = []; - - [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } - - [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } - - [CborProperty("title")] public string Title { get; set; } = string.Empty; - - [CborProperty("description")] public string Description { get; set; } = string.Empty; - - [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; - - [CborProperty("location")] public string Location { get; set; } = string.Empty; - - [CborProperty("author")] public string Author { get; set; } = string.Empty; -} - -internal sealed class CountRow -{ - [CborProperty("count")] public int Count { get; set; } -} - -internal sealed class ChallengeRow -{ - [CborProperty("id")] public string Id { get; set; } = string.Empty; - - [CborProperty("name")] public string Name { get; set; } = string.Empty; - - [CborProperty("description")] public string Description { get; set; } = string.Empty; - - [CborProperty("order")] public int Order { get; set; } -} - -internal sealed class KnowledgeItemRow -{ - [CborProperty("id")] public string Id { get; set; } = string.Empty; - - [CborProperty("title")] public string Title { get; set; } = string.Empty; - - [CborProperty("kind")] public string Kind { get; set; } = string.Empty; - - [CborProperty("challenge")] public string? Challenge { get; set; } - - [CborProperty("source_url")] public string? SourceUrl { get; set; } - - [CborProperty("attachments")] public List> Attachments { get; set; } = []; - - [CborProperty("published")] public bool Published { get; set; } - - [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } -} - -internal sealed class InnovationRow -{ - [CborProperty("id")] public string Id { get; set; } = string.Empty; - - [CborProperty("title")] public string Title { get; set; } = string.Empty; - - [CborProperty("summary")] public string Summary { get; set; } = string.Empty; - - [CborProperty("description")] public string Description { get; set; } = string.Empty; - - [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; - - [CborProperty("stage")] public string Stage { get; set; } = string.Empty; - - [CborProperty("cost")] public string? Cost { get; set; } - - [CborProperty("contact")] public Dictionary? Contact { get; set; } - - [CborProperty("media")] public List> Media { get; set; } = []; - - [CborProperty("published")] public bool Published { get; set; } - - [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } - - [CborProperty("embedding")] public List? Embedding { get; set; } -} - -internal sealed class ProblemRow -{ - [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("challenge")] public string Challenge { get; set; } = string.Empty; - - [CborProperty("location")] public string Location { get; set; } = string.Empty; - - [CborProperty("author")] public string Author { get; set; } = string.Empty; - - [CborProperty("embedding")] public List? Embedding { get; set; } -} - -internal sealed class TrendRow -{ - [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; - - [CborProperty("count")] public int Count { get; set; } -} - -internal sealed class TrendSubmissionRow -{ - [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; -} - -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; } -} diff --git a/backend/Infrastructure/Persistence/TestFeedbackRow.cs b/backend/Infrastructure/Persistence/TestFeedbackRow.cs new file mode 100644 index 0000000..21c467e --- /dev/null +++ b/backend/Infrastructure/Persistence/TestFeedbackRow.cs @@ -0,0 +1,21 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class TestFeedbackRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("innovation")] public string Innovation { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; + + [CborProperty("rating")] public int Rating { get; set; } + + [CborProperty("comment")] public string Comment { get; set; } = string.Empty; + + [CborProperty("improvements")] public string Improvements { get; set; } = string.Empty; + + [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Infrastructure/Persistence/TestFeedbackService.cs b/backend/Infrastructure/Persistence/TestFeedbackService.cs new file mode 100644 index 0000000..4f05d17 --- /dev/null +++ b/backend/Infrastructure/Persistence/TestFeedbackService.cs @@ -0,0 +1,90 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class TestFeedbackService : ITestFeedbackService +{ + private const string SelectFields = + "type::string(id) AS id, type::string(innovation) AS innovation, type::string(author) AS author, " + + "rating, comment, improvements, created_at"; + + private readonly ISurrealDbClient _client; + private readonly IInnovationService _innovations; + + public TestFeedbackService(ISurrealDbClient client, IInnovationService innovations) + { + _client = client; + _innovations = innovations; + } + + public async Task CreateAsync( + string innovationId, + string authorId, + CreateTestFeedbackRequest request, + CancellationToken cancellationToken) + { + var innovation = await _innovations.GetAsync(innovationId, cancellationToken); + + if (innovation is null) + { + return null; + } + + var feedbackId = Guid.NewGuid().ToString("N"); + var (innovationTable, innovationIdPart) = SurrealRecordId.Split(innovationId); + var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId); + + var response = await _client.RawQuery( + "CREATE type::record('test_feedback', $id) SET " + + "innovation = type::record($innovationTable, $innovationId), " + + "author = type::record($authorTable, $authorId), rating = $rating, " + + "comment = $comment, improvements = $improvements; " + + $"SELECT {SelectFields} FROM type::record('test_feedback', $id) LIMIT 1;", + new Dictionary + { + ["id"] = feedbackId, + ["innovationTable"] = innovationTable, + ["innovationId"] = innovationIdPart, + ["authorTable"] = authorTable, + ["authorId"] = authorIdPart, + ["rating"] = request.Rating, + ["comment"] = request.Comment ?? string.Empty, + ["improvements"] = request.Improvements ?? string.Empty, + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task> ListForInnovationAsync( + string innovationId, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(innovationId); + + var response = await _client.RawQuery( + $"SELECT {SelectFields} FROM test_feedback WHERE innovation = 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 TestFeedbackResponse Map(TestFeedbackRow row) => new( + row.Id, + row.Innovation, + row.Author, + row.Rating, + row.Comment, + row.Improvements, + row.CreatedAt); +} diff --git a/backend/Infrastructure/Persistence/ThreadRow.cs b/backend/Infrastructure/Persistence/ThreadRow.cs new file mode 100644 index 0000000..2d9c9c7 --- /dev/null +++ b/backend/Infrastructure/Persistence/ThreadRow.cs @@ -0,0 +1,15 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class ThreadRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("subject")] public string Subject { get; set; } = string.Empty; + + [CborProperty("participants")] public List Participants { get; set; } = []; + + [CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Infrastructure/Persistence/ThreadService.cs b/backend/Infrastructure/Persistence/ThreadService.cs new file mode 100644 index 0000000..5041508 --- /dev/null +++ b/backend/Infrastructure/Persistence/ThreadService.cs @@ -0,0 +1,204 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class ThreadService : IThreadService +{ + private const string SelectThreadFields = + "type::string(id) AS id, subject, array::map(participants, |$p| type::string($p)) AS participants, created_at"; + + private const string SelectMessageFields = + "type::string(id) AS id, type::string(thread) AS thread, type::string(author) AS author, body, created_at"; + + private readonly ISurrealDbClient _client; + + public ThreadService(ISurrealDbClient client) + { + _client = client; + } + + public async Task> ListForUserAsync( + string userId, + bool isStaff, + CancellationToken cancellationToken) + { + var parameters = new Dictionary(); + var where = string.Empty; + + if (!isStaff) + { + var (table, id) = SurrealRecordId.Split(userId); + where = " WHERE participants CONTAINS type::record($table, $id)"; + parameters["table"] = table; + parameters["id"] = id; + } + + var response = await _client.RawQuery( + $"SELECT {SelectThreadFields} FROM thread{where} ORDER BY created_at DESC;", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Select(row => Map(row, [])).ToList(); + } + + public async Task GetAsync( + string threadId, + string userId, + bool isStaff, + CancellationToken cancellationToken) + { + var thread = await LoadThreadAsync(threadId, cancellationToken); + + if (thread is null) + { + return null; + } + + if (!isStaff && !thread.Participants.Contains(userId)) + { + return null; + } + + var messages = await LoadMessagesAsync(threadId, cancellationToken); + + return Map(thread, messages); + } + + public async Task CreateAsync( + string creatorId, + CreateThreadRequest request, + CancellationToken cancellationToken) + { + var threadId = Guid.NewGuid().ToString("N"); + var participants = new List { creatorId }; + + foreach (var participant in request.Participants ?? []) + { + if (!string.IsNullOrWhiteSpace(participant) && !participants.Contains(participant)) + { + participants.Add(participant); + } + } + + var parameters = new Dictionary + { + ["id"] = threadId, + ["subject"] = request.Subject, + }; + + var recordExpressions = new List(participants.Count); + + for (var i = 0; i < participants.Count; i++) + { + var (table, id) = SurrealRecordId.Split(participants[i]); + parameters[$"table{i}"] = table; + parameters[$"id{i}"] = id; + recordExpressions.Add($"type::record($table{i}, $id{i})"); + } + + var response = await _client.RawQuery( + $"CREATE type::record('thread', $id) SET subject = $subject, " + + $"participants = [{string.Join(", ", recordExpressions)}]; " + + $"SELECT {SelectThreadFields} FROM type::record('thread', $id) LIMIT 1;", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 + ? Map(rows[0], []) + : throw new InvalidOperationException("Thread could not be loaded after creation."); + } + + public async Task AddMessageAsync( + string threadId, + string authorId, + bool isStaff, + CreateMessageRequest request, + CancellationToken cancellationToken) + { + var thread = await LoadThreadAsync(threadId, cancellationToken); + + if (thread is null || (!isStaff && !thread.Participants.Contains(authorId))) + { + return null; + } + + var messageId = Guid.NewGuid().ToString("N"); + var (threadTable, threadIdPart) = SurrealRecordId.Split(threadId); + var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId); + + var response = await _client.RawQuery( + "CREATE type::record('message', $id) SET thread = type::record($threadTable, $threadId), " + + "author = type::record($authorTable, $authorId), body = $body; " + + $"SELECT {SelectMessageFields} FROM type::record('message', $id) LIMIT 1;", + new Dictionary + { + ["id"] = messageId, + ["threadTable"] = threadTable, + ["threadId"] = threadIdPart, + ["authorTable"] = authorTable, + ["authorId"] = authorIdPart, + ["body"] = request.Body, + }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(1) ?? []; + + return rows.Count > 0 ? MapMessage(rows[0]) : null; + } + + private async Task LoadThreadAsync(string threadId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(threadId); + + var response = await _client.RawQuery( + $"SELECT {SelectThreadFields} 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 ? rows[0] : null; + } + + private async Task> LoadMessagesAsync( + string threadId, + CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(threadId); + + var response = await _client.RawQuery( + $"SELECT {SelectMessageFields} FROM message WHERE thread = type::record($table, $id) " + + "ORDER BY created_at ASC;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Select(MapMessage).ToList(); + } + + private static ThreadResponse Map(ThreadRow row, IReadOnlyList messages) => new( + row.Id, + row.Subject, + row.Participants, + row.CreatedAt, + messages); + + private static MessageResponse MapMessage(MessageRow row) => new( + row.Id, + row.Thread, + row.Author, + row.Body, + row.CreatedAt); +} diff --git a/backend/Infrastructure/Persistence/TrendRow.cs b/backend/Infrastructure/Persistence/TrendRow.cs new file mode 100644 index 0000000..67836e4 --- /dev/null +++ b/backend/Infrastructure/Persistence/TrendRow.cs @@ -0,0 +1,11 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class TrendRow +{ + [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; + + [CborProperty("count")] public int Count { get; set; } +} diff --git a/backend/Infrastructure/Persistence/TrendSubmissionRow.cs b/backend/Infrastructure/Persistence/TrendSubmissionRow.cs new file mode 100644 index 0000000..7125845 --- /dev/null +++ b/backend/Infrastructure/Persistence/TrendSubmissionRow.cs @@ -0,0 +1,9 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class TrendSubmissionRow +{ + [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; +} diff --git a/backend/Infrastructure/Persistence/UserRow.cs b/backend/Infrastructure/Persistence/UserRow.cs new file mode 100644 index 0000000..a018111 --- /dev/null +++ b/backend/Infrastructure/Persistence/UserRow.cs @@ -0,0 +1,17 @@ +using Dahomey.Cbor.Attributes; + +namespace TarninaHub.Infrastructure.Persistence; + + +internal sealed class UserRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("phone")] public string Phone { get; set; } = string.Empty; + + [CborProperty("email")] public string? Email { get; set; } + + [CborProperty("role")] public string Role { get; set; } = string.Empty; + + [CborProperty("organization")] public string? Organization { get; set; } +} diff --git a/backend/migrations/0006_notification_payload.surql b/backend/migrations/0006_notification_payload.surql new file mode 100644 index 0000000..7a72f97 --- /dev/null +++ b/backend/migrations/0006_notification_payload.surql @@ -0,0 +1,5 @@ +-- --------------------------------------------------------------------------- +-- Step 17: allow arbitrary notification payload keys (schemaless object). +-- --------------------------------------------------------------------------- + +DEFINE FIELD OVERWRITE payload ON TABLE notification TYPE object FLEXIBLE DEFAULT {}; diff --git a/backend/migrations/0007_innovation_contact.surql b/backend/migrations/0007_innovation_contact.surql new file mode 100644 index 0000000..45d3879 --- /dev/null +++ b/backend/migrations/0007_innovation_contact.surql @@ -0,0 +1,5 @@ +-- --------------------------------------------------------------------------- +-- Step 17: allow arbitrary contact keys on innovations (schemaless object). +-- --------------------------------------------------------------------------- + +DEFINE FIELD OVERWRITE contact ON TABLE innovation TYPE option FLEXIBLE;