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:
soroush.asadi
2026-06-09 11:58:20 +03:30
parent 61991bf6cd
commit e1911f58b1
18 changed files with 1137 additions and 23 deletions
@@ -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.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
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.Persistence;
namespace TeamUp.Modules.OrgBoard;
@@ -14,14 +17,13 @@ public sealed class OrgBoardModule : IModule
public void Register(IServiceCollection services, IConfiguration configuration)
{
// Skeleton: no services yet. M1 introduces this module's (internal) DbContext,
// FluentValidation validators, and domain services here.
var connectionString = configuration.GetConnectionString("Postgres")
?? 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)
{
endpoints.MapGroup($"/api/{Name}")
.WithTags("OrgBoard")
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
}
public void MapEndpoints(IEndpointRouteBuilder endpoints) => OrgBoardEndpoints.Map(endpoints);
}
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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">
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
gains an (internal) DbContext and validators. It must never reference another module. -->
<!-- Org, products, teams, seats, and the task/board model (M1). References SharedKernel only;
permission checks use ICurrentUser/IPermissionService from SharedKernel (implemented by
Identity), so OrgBoard never references another module. -->
<ItemGroup>
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="FluentValidation" />
</ItemGroup>
</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
/// the OpenAPI document is served. All tests share one container (sequential, same collection).
/// </summary>
[Collection(PostgresCollection.Name)]
public sealed class BootAndMigrateTests(PostgresFixture postgres)
public sealed class BootAndMigrateTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private static readonly string[] ExpectedSchemas =
["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,
/// invite a member, accept the invite, and confirm a Member cannot perform an owner-only action.
/// </summary>
[Collection(PostgresCollection.Name)]
public sealed class IdentityFlowTests(PostgresFixture postgres)
public sealed class IdentityFlowTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
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();
}
[CollectionDefinition(Name)]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
{
public const string Name = "postgres";
}