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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
|
||||
public record HealthResponse(string Status);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user