Skip to content

Commit d2856fc

Browse files
dcl10claude
andauthored
Feature/recommendation service (#22)
* Add RecommendationService and trigger endpoint POST /api/projects/{projectId}/recommendations dispatches a ProjectDescriptionPayload to the LLM message channel. Draft and Open projects are accepted; TeamConfirmed and Closed are rejected with 409. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add integration tests for RecommendationsController Covers 202 on Draft/Open, 404 for unknown project, 409 for TeamConfirmed/Closed, 403 without role, and mock verification that PublishAsync is called with the correct project ID. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 26ecc05 commit d2856fc

4 files changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
namespace SkillMatrixLlm.Api.Controllers;
2+
3+
using Auth;
4+
using Microsoft.AspNetCore.Authorization;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Services;
7+
8+
/// <summary>Triggers LLM-driven team recommendations for projects.</summary>
9+
[ApiController]
10+
[Route("api/projects/{projectId:guid}/recommendations")]
11+
[Authorize(nameof(AuthPolicies.CanManageProjects))]
12+
public class RecommendationsController(RecommendationService recommendations) : ControllerBase
13+
{
14+
/// <summary>
15+
/// Dispatches a team recommendation request for the specified project to the LLM service.
16+
/// The project must be in Draft or Open status.
17+
/// </summary>
18+
/// <param name="projectId">Project ID.</param>
19+
/// <returns>Accepted when the request has been queued.</returns>
20+
[HttpPost]
21+
[ProducesResponseType(StatusCodes.Status202Accepted)]
22+
[ProducesResponseType(StatusCodes.Status404NotFound)]
23+
[ProducesResponseType(StatusCodes.Status409Conflict)]
24+
public async Task<ActionResult> Trigger(Guid projectId)
25+
{
26+
try
27+
{
28+
await recommendations.TriggerAsync(projectId, HttpContext.RequestAborted);
29+
return Accepted();
30+
}
31+
catch (KeyNotFoundException ex)
32+
{
33+
return NotFound(ex.Message);
34+
}
35+
catch (InvalidOperationException ex)
36+
{
37+
return Conflict(ex.Message);
38+
}
39+
}
40+
}

backend/src/SkillMatrixLlm.Api/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
.AddScoped<ProjectService>()
136136
.AddScoped<TeamService>()
137137
.AddScoped<MembershipService>()
138+
.AddScoped<RecommendationService>()
138139
.AddTransient<MembershipEmailService>()
139140
.AddScoped<IKeycloakDataSeeder, KeycloakDataSeeder>();
140141

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace SkillMatrixLlm.Api.Services;
2+
3+
using Data;
4+
using Enums;
5+
using Messaging;
6+
using Microsoft.EntityFrameworkCore;
7+
using Models.Recommendations;
8+
9+
/// <summary>Dispatches team recommendation requests for projects to the LLM service.</summary>
10+
public class RecommendationService(AppDbContext db, IMessageChannel<ProjectDescriptionPayload> queue)
11+
{
12+
/// <summary>
13+
/// Publishes a team recommendation request for the given project.
14+
/// </summary>
15+
/// <param name="projectId">ID of the project to recommend a team for.</param>
16+
/// <param name="ct">Cancellation token.</param>
17+
/// <exception cref="KeyNotFoundException">Thrown when the project does not exist.</exception>
18+
/// <exception cref="InvalidOperationException">Thrown when the project status does not allow recommendations.</exception>
19+
public async Task TriggerAsync(Guid projectId, CancellationToken ct = default)
20+
{
21+
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct)
22+
?? throw new KeyNotFoundException($"Project {projectId} not found.");
23+
24+
if (project.Status is ProjectStatus.TeamConfirmed or ProjectStatus.Closed)
25+
{
26+
throw new InvalidOperationException(
27+
$"Cannot request a recommendation for a project with status {project.Status}.");
28+
}
29+
30+
var payload = new ProjectDescriptionPayload(
31+
project.Id,
32+
project.Title,
33+
project.Description,
34+
project.DesiredTeamSize,
35+
project.Timeline);
36+
37+
await queue.PublishAsync(payload, ct);
38+
}
39+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
namespace SkillMatrixLlm.Api.Tests;
2+
3+
using System.Net;
4+
using Constants;
5+
using Data;
6+
using Enums;
7+
using Fixtures;
8+
using Messaging;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Models.Recommendations;
11+
using Moq;
12+
using Xunit;
13+
using ProjectEntity = SkillMatrixLlm.Api.Data.Entities.Project;
14+
using UserEntity = SkillMatrixLlm.Api.Data.Entities.User;
15+
16+
public class RecommendationsControllerTests(ApiFactory factory) : IClassFixture<ApiFactory>, IAsyncLifetime
17+
{
18+
public Task InitializeAsync()
19+
{
20+
using var scope = factory.Services.CreateScope();
21+
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
22+
db.Recommendations.RemoveRange(db.Recommendations);
23+
db.TeamMemberships.RemoveRange(db.TeamMemberships);
24+
db.Teams.RemoveRange(db.Teams);
25+
db.Projects.RemoveRange(db.Projects);
26+
db.Users.RemoveRange(db.Users);
27+
db.SaveChanges();
28+
29+
Mock.Get(factory.Services.GetRequiredService<IMessageChannel<ProjectDescriptionPayload>>()).Reset();
30+
31+
return Task.CompletedTask;
32+
}
33+
34+
public Task DisposeAsync() => Task.CompletedTask;
35+
36+
private static UserEntity SeedUser(AppDbContext db)
37+
{
38+
var user = new UserEntity { KeycloakId = "test-keycloak-id", DisplayName = "PM", Email = "pm@example.com" };
39+
db.Users.Add(user);
40+
db.SaveChanges();
41+
return user;
42+
}
43+
44+
private static ProjectEntity SeedProject(AppDbContext db, UserEntity user, ProjectStatus status)
45+
{
46+
var project = new ProjectEntity
47+
{
48+
Title = "Test Project",
49+
Description = "A project description",
50+
DesiredTeamSize = 3,
51+
Timeline = "3 months",
52+
Status = status,
53+
CreatedByUserId = user.Id,
54+
CreatedAt = DateTime.UtcNow,
55+
};
56+
db.Projects.Add(project);
57+
db.SaveChanges();
58+
return project;
59+
}
60+
61+
// -------------------------------------------------------------------------
62+
// POST /api/projects/{projectId}/recommendations
63+
// -------------------------------------------------------------------------
64+
65+
[Theory]
66+
[InlineData(ProjectStatus.Draft)]
67+
[InlineData(ProjectStatus.Open)]
68+
public async Task Trigger_ReturnsAccepted_WhenProjectIsInAllowedStatus(ProjectStatus status)
69+
{
70+
using var scope = factory.Services.CreateScope();
71+
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
72+
var user = SeedUser(db);
73+
var project = SeedProject(db, user, status);
74+
75+
var client = factory.CreateAuthenticatedClient([TestClaims.Sub, TestClaims.ManageProjectsRole]);
76+
77+
var response = await client.PostAsync($"/api/projects/{project.Id}/recommendations", null);
78+
79+
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
80+
}
81+
82+
[Theory]
83+
[InlineData(ProjectStatus.Draft)]
84+
[InlineData(ProjectStatus.Open)]
85+
public async Task Trigger_PublishesPayload_WhenProjectIsInAllowedStatus(ProjectStatus status)
86+
{
87+
using var scope = factory.Services.CreateScope();
88+
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
89+
var user = SeedUser(db);
90+
var project = SeedProject(db, user, status);
91+
92+
var client = factory.CreateAuthenticatedClient([TestClaims.Sub, TestClaims.ManageProjectsRole]);
93+
94+
await client.PostAsync($"/api/projects/{project.Id}/recommendations", null);
95+
96+
var queue = factory.Services.GetRequiredService<IMessageChannel<ProjectDescriptionPayload>>();
97+
Mock.Get(queue).Verify(
98+
q => q.PublishAsync(
99+
It.Is<ProjectDescriptionPayload>(p => p.ProjectId == project.Id),
100+
It.IsAny<CancellationToken>()),
101+
Times.Once);
102+
}
103+
104+
[Fact]
105+
public async Task Trigger_ReturnsNotFound_WhenProjectDoesNotExist()
106+
{
107+
var client = factory.CreateAuthenticatedClient([TestClaims.Sub, TestClaims.ManageProjectsRole]);
108+
109+
var response = await client.PostAsync($"/api/projects/{Guid.NewGuid()}/recommendations", null);
110+
111+
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
112+
}
113+
114+
[Theory]
115+
[InlineData(ProjectStatus.TeamConfirmed)]
116+
[InlineData(ProjectStatus.Closed)]
117+
public async Task Trigger_ReturnsConflict_WhenProjectStatusDisallowsRecommendations(ProjectStatus status)
118+
{
119+
using var scope = factory.Services.CreateScope();
120+
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
121+
var user = SeedUser(db);
122+
var project = SeedProject(db, user, status);
123+
124+
var client = factory.CreateAuthenticatedClient([TestClaims.Sub, TestClaims.ManageProjectsRole]);
125+
126+
var response = await client.PostAsync($"/api/projects/{project.Id}/recommendations", null);
127+
128+
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
129+
}
130+
131+
[Fact]
132+
public async Task Trigger_ReturnsForbidden_WithoutManageProjectsRole()
133+
{
134+
var client = factory.CreateAuthenticatedClient([TestClaims.Sub]);
135+
136+
var response = await client.PostAsync($"/api/projects/{Guid.NewGuid()}/recommendations", null);
137+
138+
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
139+
}
140+
}

0 commit comments

Comments
 (0)