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

Implement step 17 modules: threads/messages, notifications with automation (submission.created, idea.created, grant_call.opened), innovation test feedback, admin panel (submissions, verify, metrics, grant call status), Middleman adaptation and partnerships. Split multi-type files into one type per file and replace object-valued dictionaries with string-valued maps in data contracts (migrations 0006/0007).
This commit is contained in:
Rafał Tyński
2026-10-04 03:49:45 +02:00
parent f4f3b3896a
commit fa1199539b
136 changed files with 2338 additions and 574 deletions
+98
View File
@@ -0,0 +1,98 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/admin")]
[Authorize(Policy = Policies.OperatorOrAdmin)]
public class AdminController : ControllerBase
{
private readonly IAdminService _admin;
private readonly IGrantCallService _grantCalls;
private readonly IIdeaService _ideas;
private readonly INotificationService _notifications;
public AdminController(
IAdminService admin,
IGrantCallService grantCalls,
IIdeaService ideas,
INotificationService notifications)
{
_admin = admin;
_grantCalls = grantCalls;
_ideas = ideas;
_notifications = notifications;
}
[HttpGet("submissions")]
public async Task<ActionResult<IReadOnlyList<SubmissionResponse>>> ListSubmissions(
[FromQuery] string? status,
[FromQuery] string? challengeId,
CancellationToken cancellationToken)
{
var submissions = await _admin.ListSubmissionsAsync(status, challengeId, cancellationToken);
return Ok(submissions);
}
[HttpPut("knowledge-items/{knowledgeItemId}/verify")]
public async Task<ActionResult<KnowledgeItemResponse>> VerifyKnowledgeItem(
string knowledgeItemId,
VerifyKnowledgeItemRequest request,
CancellationToken cancellationToken)
{
var item = await _admin.VerifyKnowledgeItemAsync(knowledgeItemId, request, cancellationToken);
return item is null ? NotFound() : Ok(item);
}
[HttpGet("metrics")]
public async Task<ActionResult<AdminMetricsResponse>> Metrics(CancellationToken cancellationToken)
{
var metrics = await _admin.GetMetricsAsync(cancellationToken);
return Ok(metrics);
}
[HttpPut("grant-calls/{grantCallId}/status")]
public async Task<ActionResult<GrantCallResponse>> UpdateGrantCallStatus(
string grantCallId,
UpdateGrantCallStatusRequest request,
CancellationToken cancellationToken)
{
var updated = await _grantCalls.UpdateStatusAsync(grantCallId, request.Status, cancellationToken);
if (updated is null)
{
return NotFound();
}
if (string.Equals(request.Status, "open", StringComparison.Ordinal))
{
await NotifyIdeaAuthorsAsync(updated, cancellationToken);
}
return Ok(updated);
}
private async Task NotifyIdeaAuthorsAsync(GrantCallResponse call, CancellationToken cancellationToken)
{
var authorIds = await _ideas.ListDistinctAuthorIdsAsync(cancellationToken);
var payload = new Dictionary<string, string?>
{
["grantCallId"] = call.Id,
["name"] = call.Name,
};
foreach (var authorId in authorIds)
{
await _notifications.NotifyUserAsync(authorId, "grant_call.opened", payload, cancellationToken);
}
}
}
+2 -2
View File
@@ -48,7 +48,7 @@ public class ConsentsController : ControllerBase
userId,
"consent.grant",
consent.Id,
new Dictionary<string, object?> { ["scope"] = request.Scope },
new Dictionary<string, string?> { ["scope"] = request.Scope },
cancellationToken);
return StatusCode(StatusCodes.Status201Created, consent);
@@ -90,7 +90,7 @@ public class ConsentsController : ControllerBase
userId,
"consent.revoke",
consentId,
new Dictionary<string, object?>(),
new Dictionary<string, string?>(),
cancellationToken);
return NoContent();
+1 -2
View File
@@ -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);
+8
View File
@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace TarninaHub.Controllers;
[ApiController]
[Route("api/health")]
public record HealthResponse(string Status);
+16 -1
View File
@@ -17,11 +17,16 @@ public class IdeasController : ControllerBase
{
private readonly IIdeaService _ideaService;
private readonly 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<string, string?>
{
["ideaId"] = created.Id,
["title"] = created.Title,
["authorId"] = created.AuthorId,
},
cancellationToken);
return CreatedAtAction(nameof(GetById), new { ideaId = created.Id }, created);
}
+35 -1
View File
@@ -16,11 +16,16 @@ public class InnovationsController : ControllerBase
{
private readonly IInnovationService _innovations;
private readonly 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<ActionResult<TestFeedbackResponse>> CreateTestFeedback(
string innovationId,
CreateTestFeedbackRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var feedback = await _testFeedback.CreateAsync(innovationId, userId, request, cancellationToken);
return feedback is null ? NotFound() : StatusCode(StatusCodes.Status201Created, feedback);
}
[HttpGet("{innovationId}/test-feedback")]
[Authorize(Policy = Policies.ManagerOrAbove)]
public async Task<ActionResult<IReadOnlyList<TestFeedbackResponse>>> ListTestFeedback(
string innovationId,
CancellationToken cancellationToken)
{
var feedback = await _testFeedback.ListForInnovationAsync(innovationId, cancellationToken);
return Ok(feedback);
}
private bool ValidateStage(string? stage) => InnovationStageWire.IsValid(stage);
private ActionResult InvalidStage()
+1 -2
View File
@@ -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);
@@ -0,0 +1,14 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/matchmaking")]
[Authorize]
public sealed record MatchmakingReindexResponse(int Indexed);
@@ -0,0 +1,32 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/middleman")]
[Authorize(Policy = Policies.JstOrAbove)]
public class MiddlemanController : ControllerBase
{
private readonly IMiddlemanService _middleman;
public MiddlemanController(IMiddlemanService middleman)
{
_middleman = middleman;
}
[HttpPost("adapt")]
public async Task<ActionResult<MiddlemanResponse>> Adapt(
MiddlemanRequest request,
CancellationToken cancellationToken)
{
var response = await _middleman.AdaptAsync(request, cancellationToken);
return response is null ? NotFound() : Ok(response);
}
}
@@ -0,0 +1,54 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/notifications")]
[Authorize]
public class NotificationsController : ControllerBase
{
private readonly INotificationService _notifications;
public NotificationsController(INotificationService notifications)
{
_notifications = notifications;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<NotificationResponse>>> List(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var notifications = await _notifications.ListForUserAsync(userId, cancellationToken);
return Ok(notifications);
}
[HttpPost("{notificationId}/read")]
public async Task<ActionResult<NotificationResponse>> MarkRead(
string notificationId,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var notification = await _notifications.MarkReadAsync(notificationId, userId, cancellationToken);
return notification is null ? NotFound() : Ok(notification);
}
}
@@ -0,0 +1,57 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/partnerships")]
[Authorize]
public class PartnershipsController : ControllerBase
{
private readonly IPartnershipService _partnerships;
public PartnershipsController(IPartnershipService partnerships)
{
_partnerships = partnerships;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<PartnershipResponse>>> List(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var partnerships = await _partnerships.ListForUserAsync(userId, IsStaff(), cancellationToken);
return Ok(partnerships);
}
[HttpPost]
public async Task<ActionResult<PartnershipResponse>> Create(
CreatePartnershipRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var created = await _partnerships.CreateAsync(userId, request, cancellationToken);
return StatusCode(StatusCodes.Status201Created, created);
}
private bool IsStaff() =>
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
}
+14 -1
View File
@@ -17,13 +17,16 @@ public class SubmissionsController : ControllerBase
{
private readonly ISubmissionService _submissionService;
private readonly 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<string, string?>
{
["submissionId"] = created.Id,
["title"] = created.Title,
["authorId"] = created.AuthorId,
},
cancellationToken);
return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created);
}
+101
View File
@@ -0,0 +1,101 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/threads")]
[Authorize]
public class ThreadsController : ControllerBase
{
private readonly IThreadService _threads;
public ThreadsController(IThreadService threads)
{
_threads = threads;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ThreadResponse>>> List(CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var threads = await _threads.ListForUserAsync(userId, IsStaff(), cancellationToken);
return Ok(threads);
}
[HttpPost]
public async Task<ActionResult<ThreadResponse>> Create(
CreateThreadRequest request,
CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var created = await _threads.CreateAsync(userId, request, cancellationToken);
return CreatedAtAction(nameof(GetById), new { threadId = created.Id }, created);
}
[HttpGet("{threadId}")]
public async Task<ActionResult<ThreadResponse>> GetById(
string threadId,
CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var thread = await _threads.GetAsync(threadId, userId, IsStaff(), cancellationToken);
return thread is null ? NotFound() : Ok(thread);
}
[HttpPost("{threadId}/messages")]
public async Task<ActionResult<MessageResponse>> AddMessage(
string threadId,
CreateMessageRequest request,
CancellationToken cancellationToken)
{
var userId = RequireUserId(out var unauthorized);
if (unauthorized is not null)
{
return unauthorized;
}
var message = await _threads.AddMessageAsync(threadId, userId, IsStaff(), request, cancellationToken);
return message is null ? NotFound() : StatusCode(StatusCodes.Status201Created, message);
}
private string RequireUserId(out ActionResult? unauthorized)
{
var userId = User.GetUserId();
unauthorized = string.IsNullOrWhiteSpace(userId) ? Unauthorized() : null;
return userId ?? string.Empty;
}
private bool IsStaff() =>
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
}
+1 -1
View File
@@ -64,7 +64,7 @@ public class UsersController : ControllerBase
currentUserId,
"user.read",
userId,
new Dictionary<string, object?> { ["reason"] = "staff-lookup" },
new Dictionary<string, string?> { ["reason"] = "staff-lookup" },
cancellationToken);
}