Org structure: divisions → products/services → teams + custom model base URL

The object spine becomes definable (data model was designed-for from day one):
- Division and Product entities (Product carries kind: Product|Service, optional DivisionId);
  Team gains nullable ProductId — pre-structure teams keep working. AddDivisionsAndProducts
  migration; org-scoped validation; owner-only writes (audited); list endpoints.
- /structure page: define divisions, products/services (with division), teams (under a
  product). Org chart now renders the full spine — org → divisions → products → teams →
  seats — with parentless layers linking up to the org.
- BYOK custom URL: the SeatsPage model-connection form gains a Base URL field (provider
  list: stub/openai/ollama/vllm/custom). Backend already supported it end to end —
  ApiConfig.Endpoint flows into the OpenAI-compatible adapter ({base}/v1/chat/completions),
  so any OpenAI-compatible gateway or self-hosted model works; the config list shows it.

Verified: ArchitectureTests 8/8, IntegrationTests 45/45 (new OrgStructureTests: spine
creation, kind tags, org-scoped validation 400s, Member 403), client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 18:13:52 +03:30
parent 4416d99360
commit 1e65654114
15 changed files with 1153 additions and 21 deletions
@@ -18,6 +18,10 @@ internal static class OrgBoardEndpoints
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("orgboard")));
group.MapPost("/organizations", CreateOrganization).RequireAuthorization();
group.MapPost("/divisions", CreateDivision).RequireAuthorization();
group.MapGet("/divisions", ListDivisions).RequireAuthorization();
group.MapPost("/products", CreateProduct).RequireAuthorization();
group.MapGet("/products", ListProducts).RequireAuthorization();
group.MapPost("/teams", CreateTeam).RequireAuthorization();
group.MapGet("/teams", ListTeams).RequireAuthorization();
group.MapPost("/tasks", CreateTask).RequireAuthorization();
@@ -87,11 +91,97 @@ internal static class OrgBoardEndpoints
return Results.BadRequest("Organization does not exist; create it first.");
}
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
if (request.ProductId is { } productId
&& !await db.Products.AnyAsync(p => p.Id == productId && p.OrganizationId == request.OrganizationId, ct))
{
return Results.BadRequest("Product not found in this organization.");
}
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow(), request.ProductId);
db.Teams.Add(team);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct);
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name, team.ProductId));
}
private static async Task<IResult> CreateDivision(
CreateDivisionRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var division = new Division(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
db.Divisions.Add(division);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("division.created", "Division", division.Id, user.MemberId, division.Name), ct);
return Results.Ok(new DivisionResponse(division.Id, division.OrganizationId, division.Name));
}
private static async Task<IResult> ListDivisions(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var divisions = await db.Divisions
.Where(d => d.OrganizationId == organizationId)
.OrderBy(d => d.CreatedAtUtc)
.Select(d => new DivisionResponse(d.Id, d.OrganizationId, d.Name))
.ToListAsync(ct);
return Results.Ok(divisions);
}
private static async Task<IResult> CreateProduct(
CreateProductRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
if (request.DivisionId is { } divisionId
&& !await db.Divisions.AnyAsync(d => d.Id == divisionId && d.OrganizationId == request.OrganizationId, ct))
{
return Results.BadRequest("Division not found in this organization.");
}
var product = new Product(request.OrganizationId, request.DivisionId, request.Name.Trim(), request.Kind, clock.GetUtcNow());
db.Products.Add(product);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("product.created", "Product", product.Id, user.MemberId, product.Name), ct);
return Results.Ok(new ProductResponse(product.Id, product.OrganizationId, product.DivisionId, product.Name, product.Kind.ToString()));
}
private static async Task<IResult> ListProducts(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var products = await db.Products
.Where(p => p.OrganizationId == organizationId)
.OrderBy(p => p.CreatedAtUtc)
.Select(p => new ProductResponse(p.Id, p.OrganizationId, p.DivisionId, p.Name, p.Kind.ToString()))
.ToListAsync(ct);
return Results.Ok(products);
}
private static async Task<IResult> ListTeams(
@@ -105,7 +195,7 @@ internal static class OrgBoardEndpoints
var teams = await db.Teams
.Where(t => t.OrganizationId == organizationId)
.OrderBy(t => t.CreatedAtUtc)
.Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name))
.Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name, t.ProductId))
.ToListAsync(ct);
return Results.Ok(teams);