M1: OrgBoard — organizations, teams, seats, the board & cartable
OrgBoard module (references SharedKernel only; RBAC via ICurrentUser/IPermissionService):
- Organization, Team, Seat (human/open/ai), WorkItem (board task: type, status, assignee,
parent) entities; internal OrgBoardDbContext (schema "orgboard") + InitialOrgBoard
migration; design-time factory. (WorkItem avoids the System.Threading.Tasks.Task clash.)
- Endpoints under /api/orgboard, every mutation permission-checked at the scope chain
[team, org]: POST /organizations, POST/GET /teams, POST /tasks, GET /board (columns
backlog->in progress->in review->done), PATCH /tasks/{id}/move, /assign, GET /cartable.
Test isolation: integration tests now use IClassFixture so each class gets its own
pgvector container (the bootstrap-once rule made a shared container collide).
Verified: build green; ArchitectureTests 8/8 (OrgBoard references only SharedKernel);
IntegrationTests 12/12 incl. a new board flow — owner sets up org+team, creates/moves/
assigns a task, sees it on the board and in the cartable; an invited Member can view the
board but is 403'd from creating a team.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
/// <summary>The seat-state triad — the load-bearing concept of the UI (human / open / AI).</summary>
|
||||||
|
internal enum SeatState
|
||||||
|
{
|
||||||
|
Human,
|
||||||
|
Open,
|
||||||
|
Ai,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum WorkItemType
|
||||||
|
{
|
||||||
|
Spec,
|
||||||
|
Story,
|
||||||
|
Test,
|
||||||
|
Review,
|
||||||
|
Release,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The board columns: backlog → in progress → in review → done.</summary>
|
||||||
|
internal enum WorkItemStatus
|
||||||
|
{
|
||||||
|
Backlog,
|
||||||
|
InProgress,
|
||||||
|
InReview,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum AssigneeKind
|
||||||
|
{
|
||||||
|
Unassigned,
|
||||||
|
Member,
|
||||||
|
Agent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
/// <summary>The company. Its id is the Organization scope that org-level memberships are granted at.</summary>
|
||||||
|
internal sealed class Organization : Entity
|
||||||
|
{
|
||||||
|
public string Name { get; private set; } = null!;
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private Organization()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Organization(Guid id, string name, DateTimeOffset createdAtUtc)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
CreatedAtUtc = createdAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Rename(string name) => Name = name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
/// <summary>A role on a team, in one of three states: human / open / AI. AI seats are configured in M3.</summary>
|
||||||
|
internal sealed class Seat : Entity
|
||||||
|
{
|
||||||
|
public Guid TeamId { get; private set; }
|
||||||
|
public string RoleName { get; private set; } = null!;
|
||||||
|
public SeatState State { get; private set; }
|
||||||
|
public Guid? MemberId { get; private set; }
|
||||||
|
public Guid? AgentId { get; private set; }
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private Seat()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Seat(Guid teamId, string roleName, SeatState state, DateTimeOffset createdAtUtc, Guid? memberId = null)
|
||||||
|
{
|
||||||
|
TeamId = teamId;
|
||||||
|
RoleName = roleName;
|
||||||
|
State = state;
|
||||||
|
MemberId = memberId;
|
||||||
|
CreatedAtUtc = createdAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AssignMember(Guid memberId)
|
||||||
|
{
|
||||||
|
MemberId = memberId;
|
||||||
|
AgentId = null;
|
||||||
|
State = SeatState.Human;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Open()
|
||||||
|
{
|
||||||
|
MemberId = null;
|
||||||
|
AgentId = null;
|
||||||
|
State = SeatState.Open;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
/// <summary>A team within an organization. Team-level memberships are granted at its id (Team scope).</summary>
|
||||||
|
internal sealed class Team : Entity
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; private set; }
|
||||||
|
public string Name { get; private set; } = null!;
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private Team()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Team(Guid organizationId, string name, DateTimeOffset createdAtUtc)
|
||||||
|
{
|
||||||
|
OrganizationId = organizationId;
|
||||||
|
Name = name;
|
||||||
|
CreatedAtUtc = createdAtUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
/// <summary>A board task. Humans and AI share this one model — the assignee is a member or an agent.</summary>
|
||||||
|
internal sealed class WorkItem : Entity
|
||||||
|
{
|
||||||
|
public Guid TeamId { get; private set; }
|
||||||
|
public string Title { get; private set; } = null!;
|
||||||
|
public string? Description { get; private set; }
|
||||||
|
public WorkItemType Type { get; private set; }
|
||||||
|
public WorkItemStatus Status { get; private set; }
|
||||||
|
public AssigneeKind AssigneeKind { get; private set; }
|
||||||
|
public Guid? AssigneeId { get; private set; }
|
||||||
|
public Guid? ParentId { get; private set; }
|
||||||
|
public Guid CreatedByMemberId { get; private set; }
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private WorkItem()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkItem(
|
||||||
|
Guid teamId,
|
||||||
|
string title,
|
||||||
|
string? description,
|
||||||
|
WorkItemType type,
|
||||||
|
Guid createdByMemberId,
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
Guid? parentId = null)
|
||||||
|
{
|
||||||
|
TeamId = teamId;
|
||||||
|
Title = title;
|
||||||
|
Description = description;
|
||||||
|
Type = type;
|
||||||
|
Status = WorkItemStatus.Backlog;
|
||||||
|
AssigneeKind = AssigneeKind.Unassigned;
|
||||||
|
CreatedByMemberId = createdByMemberId;
|
||||||
|
ParentId = parentId;
|
||||||
|
CreatedAtUtc = nowUtc;
|
||||||
|
UpdatedAtUtc = nowUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MoveTo(WorkItemStatus status, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
Status = status;
|
||||||
|
UpdatedAtUtc = nowUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AssignToMember(Guid memberId, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
AssigneeKind = AssigneeKind.Member;
|
||||||
|
AssigneeId = memberId;
|
||||||
|
UpdatedAtUtc = nowUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unassign(DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
AssigneeKind = AssigneeKind.Unassigned;
|
||||||
|
AssigneeId = null;
|
||||||
|
UpdatedAtUtc = nowUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||||
|
|
||||||
|
internal sealed record CreateOrganizationRequest(Guid OrganizationId, string Name);
|
||||||
|
|
||||||
|
internal sealed record OrganizationResponse(Guid Id, string Name);
|
||||||
|
|
||||||
|
internal sealed record CreateTeamRequest(Guid OrganizationId, string Name);
|
||||||
|
|
||||||
|
internal sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||||
|
|
||||||
|
internal sealed record CreateTaskRequest(Guid TeamId, string Title, string? Description, WorkItemType Type);
|
||||||
|
|
||||||
|
internal sealed record MoveTaskRequest(WorkItemStatus Status);
|
||||||
|
|
||||||
|
internal sealed record AssignTaskRequest(Guid MemberId);
|
||||||
|
|
||||||
|
internal sealed record TaskResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid TeamId,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
string Type,
|
||||||
|
string Status,
|
||||||
|
string AssigneeKind,
|
||||||
|
Guid? AssigneeId,
|
||||||
|
Guid? ParentId);
|
||||||
|
|
||||||
|
internal sealed record BoardColumn(string Status, IReadOnlyList<TaskResponse> Items);
|
||||||
|
|
||||||
|
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> Columns);
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
using TeamUp.Modules.OrgBoard.Persistence;
|
||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||||
|
|
||||||
|
internal static class OrgBoardEndpoints
|
||||||
|
{
|
||||||
|
public static void Map(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
var group = endpoints.MapGroup("/api/orgboard").WithTags("OrgBoard");
|
||||||
|
|
||||||
|
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("orgboard")));
|
||||||
|
group.MapPost("/organizations", CreateOrganization).RequireAuthorization();
|
||||||
|
group.MapPost("/teams", CreateTeam).RequireAuthorization();
|
||||||
|
group.MapGet("/teams", ListTeams).RequireAuthorization();
|
||||||
|
group.MapPost("/tasks", CreateTask).RequireAuthorization();
|
||||||
|
group.MapGet("/board", GetBoard).RequireAuthorization();
|
||||||
|
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
|
||||||
|
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
||||||
|
group.MapGet("/cartable", Cartable).RequireAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||||
|
item.Id, item.TeamId, item.Title, item.Description,
|
||||||
|
item.Type.ToString(), item.Status.ToString(), item.AssigneeKind.ToString(),
|
||||||
|
item.AssigneeId, item.ParentId);
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateOrganization(
|
||||||
|
CreateOrganizationRequest request, IPermissionService permissions,
|
||||||
|
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 organization = await db.Organizations.FirstOrDefaultAsync(o => o.Id == request.OrganizationId, ct);
|
||||||
|
if (organization is null)
|
||||||
|
{
|
||||||
|
organization = new Organization(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
||||||
|
db.Organizations.Add(organization);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
organization.Rename(request.Name.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new OrganizationResponse(organization.Id, organization.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateTeam(
|
||||||
|
CreateTeamRequest request, IPermissionService permissions,
|
||||||
|
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 (!await db.Organizations.AnyAsync(o => o.Id == request.OrganizationId, ct))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Organization does not exist; create it first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
||||||
|
db.Teams.Add(team);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListTeams(
|
||||||
|
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var teams = await db.Teams
|
||||||
|
.Where(t => t.OrganizationId == organizationId)
|
||||||
|
.OrderBy(t => t.CreatedAtUtc)
|
||||||
|
.Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateTask(
|
||||||
|
CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
|
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
|
||||||
|
if (team is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Team not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Title is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
|
||||||
|
db.WorkItems.Add(item);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(ToResponse(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetBoard(
|
||||||
|
Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct);
|
||||||
|
if (team is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("Team not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await db.WorkItems.Where(w => w.TeamId == teamId).OrderBy(w => w.CreatedAtUtc).ToListAsync(ct);
|
||||||
|
var columns = Enum.GetValues<WorkItemStatus>()
|
||||||
|
.Select(status => new BoardColumn(
|
||||||
|
status.ToString(),
|
||||||
|
items.Where(i => i.Status == status).Select(ToResponse).ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(new BoardResponse(teamId, columns));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> MoveTask(
|
||||||
|
Guid id, MoveTaskRequest request, IPermissionService permissions,
|
||||||
|
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team!.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
item!.MoveTo(request.Status, clock.GetUtcNow());
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(ToResponse(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AssignTask(
|
||||||
|
Guid id, AssignTaskRequest request, IPermissionService permissions,
|
||||||
|
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team!.Id), ScopeRef.Org(team.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
item!.AssignToMember(request.MemberId, clock.GetUtcNow());
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(ToResponse(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var memberId = user.MemberId;
|
||||||
|
var items = await db.WorkItems
|
||||||
|
.Where(w => w.AssigneeKind == AssigneeKind.Member && w.AssigneeId == memberId)
|
||||||
|
.OrderByDescending(w => w.UpdatedAtUtc)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(items.Select(ToResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(WorkItem? Item, Team? Team, IResult? Error)> LoadItemWithTeam(
|
||||||
|
OrgBoardDbContext db, Guid itemId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == itemId, ct);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return (null, null, Results.NotFound("Task not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == item.TeamId, ct);
|
||||||
|
if (team is null)
|
||||||
|
{
|
||||||
|
return (null, null, Results.NotFound("Team not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (item, team, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using TeamUp.Modules.OrgBoard.Endpoints;
|
||||||
|
using TeamUp.Modules.OrgBoard.Persistence;
|
||||||
using TeamUp.SharedKernel.Modularity;
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
using TeamUp.SharedKernel.Persistence;
|
||||||
|
|
||||||
namespace TeamUp.Modules.OrgBoard;
|
namespace TeamUp.Modules.OrgBoard;
|
||||||
|
|
||||||
@@ -14,14 +17,13 @@ public sealed class OrgBoardModule : IModule
|
|||||||
|
|
||||||
public void Register(IServiceCollection services, IConfiguration configuration)
|
public void Register(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Skeleton: no services yet. M1 introduces this module's (internal) DbContext,
|
var connectionString = configuration.GetConnectionString("Postgres")
|
||||||
// FluentValidation validators, and domain services here.
|
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
|
||||||
|
|
||||||
|
services.AddDbContext<OrgBoardDbContext>(options => options.UseNpgsql(connectionString));
|
||||||
|
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<OrgBoardDbContext>());
|
||||||
|
services.TryAddSingleton(TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
public void MapEndpoints(IEndpointRouteBuilder endpoints) => OrgBoardEndpoints.Map(endpoints);
|
||||||
{
|
|
||||||
endpoints.MapGroup($"/api/{Name}")
|
|
||||||
.WithTags("OrgBoard")
|
|
||||||
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeamUp.Modules.OrgBoard.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OrgBoardDbContext))]
|
||||||
|
[Migration("20260609043906_InitialOrgBoard")]
|
||||||
|
partial class InitialOrgBoard
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("orgboard")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("organizations", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AgentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RoleName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.ToTable("seats", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.ToTable("teams", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssigneeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssigneeKind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.HasIndex("AssigneeKind", "AssigneeId");
|
||||||
|
|
||||||
|
b.ToTable("work_items", "orgboard");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialOrgBoard : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: "orgboard");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "organizations",
|
||||||
|
schema: "orgboard",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_organizations", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "seats",
|
||||||
|
schema: "orgboard",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
RoleName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||||
|
State = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||||
|
MemberId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
AgentId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_seats", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "teams",
|
||||||
|
schema: "orgboard",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_teams", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "work_items",
|
||||||
|
schema: "orgboard",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Type = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||||
|
AssigneeKind = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||||
|
AssigneeId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CreatedByMemberId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_work_items", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_seats_TeamId",
|
||||||
|
schema: "orgboard",
|
||||||
|
table: "seats",
|
||||||
|
column: "TeamId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_teams_OrganizationId",
|
||||||
|
schema: "orgboard",
|
||||||
|
table: "teams",
|
||||||
|
column: "OrganizationId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_work_items_AssigneeKind_AssigneeId",
|
||||||
|
schema: "orgboard",
|
||||||
|
table: "work_items",
|
||||||
|
columns: new[] { "AssigneeKind", "AssigneeId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_work_items_TeamId",
|
||||||
|
schema: "orgboard",
|
||||||
|
table: "work_items",
|
||||||
|
column: "TeamId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "organizations",
|
||||||
|
schema: "orgboard");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "seats",
|
||||||
|
schema: "orgboard");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "teams",
|
||||||
|
schema: "orgboard");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "work_items",
|
||||||
|
schema: "orgboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeamUp.Modules.OrgBoard.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OrgBoardDbContext))]
|
||||||
|
partial class OrgBoardDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("orgboard")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("organizations", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AgentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RoleName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.ToTable("seats", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.ToTable("teams", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssigneeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssigneeKind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.HasIndex("AssigneeKind", "AssigneeId");
|
||||||
|
|
||||||
|
b.ToTable("work_items", "orgboard");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
using TeamUp.SharedKernel.Persistence;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence;
|
||||||
|
|
||||||
|
internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> options)
|
||||||
|
: DbContext(options), IModuleDbContext
|
||||||
|
{
|
||||||
|
public DbSet<Organization> Organizations => Set<Organization>();
|
||||||
|
public DbSet<Team> Teams => Set<Team>();
|
||||||
|
public DbSet<Seat> Seats => Set<Seat>();
|
||||||
|
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema("orgboard");
|
||||||
|
|
||||||
|
modelBuilder.Entity<Organization>(organization =>
|
||||||
|
{
|
||||||
|
organization.ToTable("organizations");
|
||||||
|
organization.HasKey(o => o.Id);
|
||||||
|
organization.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Team>(team =>
|
||||||
|
{
|
||||||
|
team.ToTable("teams");
|
||||||
|
team.HasKey(t => t.Id);
|
||||||
|
team.Property(t => t.Name).HasMaxLength(200).IsRequired();
|
||||||
|
team.HasIndex(t => t.OrganizationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Seat>(seat =>
|
||||||
|
{
|
||||||
|
seat.ToTable("seats");
|
||||||
|
seat.HasKey(s => s.Id);
|
||||||
|
seat.Property(s => s.RoleName).HasMaxLength(120).IsRequired();
|
||||||
|
seat.Property(s => s.State).HasConversion<string>().HasMaxLength(16);
|
||||||
|
seat.HasIndex(s => s.TeamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<WorkItem>(workItem =>
|
||||||
|
{
|
||||||
|
workItem.ToTable("work_items");
|
||||||
|
workItem.HasKey(w => w.Id);
|
||||||
|
workItem.Property(w => w.Title).HasMaxLength(300).IsRequired();
|
||||||
|
workItem.Property(w => w.Type).HasConversion<string>().HasMaxLength(16);
|
||||||
|
workItem.Property(w => w.Status).HasConversion<string>().HasMaxLength(16);
|
||||||
|
workItem.Property(w => w.AssigneeKind).HasConversion<string>().HasMaxLength(16);
|
||||||
|
workItem.HasIndex(w => w.TeamId);
|
||||||
|
workItem.HasIndex(w => new { w.AssigneeKind, w.AssigneeId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence;
|
||||||
|
|
||||||
|
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
|
||||||
|
internal sealed class OrgBoardDbContextFactory : IDesignTimeDbContextFactory<OrgBoardDbContext>
|
||||||
|
{
|
||||||
|
public OrgBoardDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var connectionString =
|
||||||
|
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
|
||||||
|
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<OrgBoardDbContext>()
|
||||||
|
.UseNpgsql(connectionString)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new OrgBoardDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
|
<!-- Org, products, teams, seats, and the task/board model (M1). References SharedKernel only;
|
||||||
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
|
permission checks use ICurrentUser/IPermissionService from SharedKernel (implemented by
|
||||||
gains an (internal) DbContext and validators. It must never reference another module. -->
|
Identity), so OrgBoard never references another module. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
<PackageReference Include="FluentValidation" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamUp.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1 board acceptance at the API level: an owner sets up the org + a team, creates a task, moves
|
||||||
|
/// it across columns, assigns it, and sees it on the board and in the cartable. An invited Member
|
||||||
|
/// can view the board but cannot create a team (owner-only).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
|
{
|
||||||
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||||
|
|
||||||
|
private sealed record AuthResponse(string Token, Guid MemberId);
|
||||||
|
|
||||||
|
private sealed record InviteResponse(Guid InvitationId, string Token);
|
||||||
|
|
||||||
|
private sealed record OrganizationResponse(Guid Id, string Name);
|
||||||
|
|
||||||
|
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||||
|
|
||||||
|
private sealed record TaskResponse(
|
||||||
|
Guid Id, Guid TeamId, string Title, string? Description, string Type,
|
||||||
|
string Status, string AssigneeKind, Guid? AssigneeId, Guid? ParentId);
|
||||||
|
|
||||||
|
private sealed record BoardColumn(string Status, List<TaskResponse> Items);
|
||||||
|
|
||||||
|
private sealed record BoardResponse(Guid TeamId, List<BoardColumn> Columns);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owner_builds_board_and_member_is_scoped()
|
||||||
|
{
|
||||||
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||||
|
using var anon = factory.CreateClient();
|
||||||
|
|
||||||
|
var owner = await Bootstrap(anon);
|
||||||
|
using var ownerClient = Authed(factory, owner.Token);
|
||||||
|
|
||||||
|
// Set the organization name (idempotent upsert on the bootstrapped org scope).
|
||||||
|
var org = await PostOk<OrganizationResponse>(ownerClient, "/api/orgboard/organizations",
|
||||||
|
new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
|
||||||
|
Assert.Equal(owner.OrganizationId, org.Id);
|
||||||
|
|
||||||
|
// Create a team and list it.
|
||||||
|
var team = await PostOk<TeamResponse>(ownerClient, "/api/orgboard/teams",
|
||||||
|
new { organizationId = owner.OrganizationId, name = "IPNOPS" });
|
||||||
|
var teams = await ownerClient.GetFromJsonAsync<List<TeamResponse>>(
|
||||||
|
$"/api/orgboard/teams?organizationId={owner.OrganizationId}");
|
||||||
|
Assert.Contains(teams!, t => t.Id == team.Id);
|
||||||
|
|
||||||
|
// Create a task → it lands in Backlog, unassigned.
|
||||||
|
var task = await PostOk<TaskResponse>(ownerClient, "/api/orgboard/tasks",
|
||||||
|
new { teamId = team.Id, title = "Build the login screen", description = "M1", type = "Story" });
|
||||||
|
Assert.Equal("Backlog", task.Status);
|
||||||
|
Assert.Equal("Unassigned", task.AssigneeKind);
|
||||||
|
|
||||||
|
// Move it to In Progress and assign it to the owner.
|
||||||
|
var moved = await PatchOk<TaskResponse>(ownerClient, $"/api/orgboard/tasks/{task.Id}/move",
|
||||||
|
new { status = "InProgress" });
|
||||||
|
Assert.Equal("InProgress", moved.Status);
|
||||||
|
|
||||||
|
var assigned = await PatchOk<TaskResponse>(ownerClient, $"/api/orgboard/tasks/{task.Id}/assign",
|
||||||
|
new { memberId = owner.MemberId });
|
||||||
|
Assert.Equal("Member", assigned.AssigneeKind);
|
||||||
|
Assert.Equal(owner.MemberId, assigned.AssigneeId);
|
||||||
|
|
||||||
|
// The board shows it under In Progress.
|
||||||
|
var board = await ownerClient.GetFromJsonAsync<BoardResponse>($"/api/orgboard/board?teamId={team.Id}");
|
||||||
|
var inProgress = board!.Columns.Single(c => c.Status == "InProgress");
|
||||||
|
Assert.Contains(inProgress.Items, i => i.Id == task.Id);
|
||||||
|
|
||||||
|
// The owner's cartable shows the assigned task.
|
||||||
|
var cartable = await ownerClient.GetFromJsonAsync<List<TaskResponse>>("/api/orgboard/cartable");
|
||||||
|
Assert.Contains(cartable!, i => i.Id == task.Id);
|
||||||
|
|
||||||
|
// Invite a Member at the org scope and accept.
|
||||||
|
var invite = await PostOk<InviteResponse>(ownerClient, "/api/identity/invitations", new
|
||||||
|
{
|
||||||
|
email = "dev@alia.test",
|
||||||
|
scopeType = "Organization",
|
||||||
|
scopeId = owner.OrganizationId,
|
||||||
|
role = "Member",
|
||||||
|
organizationId = owner.OrganizationId,
|
||||||
|
});
|
||||||
|
var member = await PostOk<AuthResponse>(anon, "/api/identity/invitations/accept",
|
||||||
|
new { token = invite.Token, displayName = "Dev", password = "Passw0rd!" });
|
||||||
|
|
||||||
|
using var memberClient = Authed(factory, member.Token);
|
||||||
|
|
||||||
|
// The member can view the board…
|
||||||
|
var memberBoard = await memberClient.GetAsync($"/api/orgboard/board?teamId={team.Id}");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, memberBoard.StatusCode);
|
||||||
|
|
||||||
|
// …but cannot create a team (owner-only).
|
||||||
|
var memberTeam = await memberClient.PostAsJsonAsync("/api/orgboard/teams",
|
||||||
|
new { organizationId = owner.OrganizationId, name = "Nope" });
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, memberTeam.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<BootstrapResponse> Bootstrap(HttpClient client)
|
||||||
|
{
|
||||||
|
var response = await PostOk<BootstrapResponse>(client, "/api/identity/bootstrap", new
|
||||||
|
{
|
||||||
|
organizationName = "AliaSaaS",
|
||||||
|
ownerEmail = "owner@alia.test",
|
||||||
|
ownerDisplayName = "Owner",
|
||||||
|
ownerPassword = "Passw0rd!",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
|
||||||
|
{
|
||||||
|
var response = await client.PostAsJsonAsync(url, body);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var value = await response.Content.ReadFromJsonAsync<T>();
|
||||||
|
Assert.NotNull(value);
|
||||||
|
return value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> PatchOk<T>(HttpClient client, string url, object body)
|
||||||
|
{
|
||||||
|
var response = await client.PatchAsJsonAsync(url, body);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var value = await response.Content.ReadFromJsonAsync<T>();
|
||||||
|
Assert.NotNull(value);
|
||||||
|
return value!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,7 @@ namespace TeamUp.IntegrationTests;
|
|||||||
/// extension + the 7 module schemas), health is green, every module endpoint seam is wired, and
|
/// extension + the 7 module schemas), health is green, every module endpoint seam is wired, and
|
||||||
/// the OpenAPI document is served. All tests share one container (sequential, same collection).
|
/// the OpenAPI document is served. All tests share one container (sequential, same collection).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection(PostgresCollection.Name)]
|
public sealed class BootAndMigrateTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
public sealed class BootAndMigrateTests(PostgresFixture postgres)
|
|
||||||
{
|
{
|
||||||
private static readonly string[] ExpectedSchemas =
|
private static readonly string[] ExpectedSchemas =
|
||||||
["identity", "orgboard", "skills", "integrations", "memory", "assembler", "governance"];
|
["identity", "orgboard", "skills", "integrations", "memory", "assembler", "governance"];
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ namespace TeamUp.IntegrationTests;
|
|||||||
/// M1 Identity/access acceptance at the API level: bootstrap the first owner, log in, read /me,
|
/// M1 Identity/access acceptance at the API level: bootstrap the first owner, log in, read /me,
|
||||||
/// invite a member, accept the invite, and confirm a Member cannot perform an owner-only action.
|
/// invite a member, accept the invite, and confirm a Member cannot perform an owner-only action.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection(PostgresCollection.Name)]
|
public sealed class IdentityFlowTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
public sealed class IdentityFlowTests(PostgresFixture postgres)
|
|
||||||
{
|
{
|
||||||
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,3 @@ public sealed class PostgresFixture : IAsyncLifetime
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
|
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[CollectionDefinition(Name)]
|
|
||||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
|
|
||||||
{
|
|
||||||
public const string Name = "postgres";
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user