From e1911f58b19bbc67e8a07dcbf5c2e7e67fa4776f Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 9 Jun 2026 11:58:20 +0330 Subject: [PATCH] =?UTF-8?q?M1:=20OrgBoard=20=E2=80=94=20organizations,=20t?= =?UTF-8?q?eams,=20seats,=20the=20board=20&=20cartable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../TeamUp.Modules.OrgBoard/Domain/Enums.cs | 34 +++ .../Domain/Organization.cs | 23 ++ .../TeamUp.Modules.OrgBoard/Domain/Seat.cs | 41 ++++ .../TeamUp.Modules.OrgBoard/Domain/Team.cs | 22 ++ .../Domain/WorkItem.cs | 64 +++++ .../Endpoints/OrgBoardDtos.cs | 32 +++ .../Endpoints/OrgBoardEndpoints.cs | 223 ++++++++++++++++++ .../TeamUp.Modules.OrgBoard/OrgBoardModule.cs | 22 +- ...20260609043906_InitialOrgBoard.Designer.cs | 165 +++++++++++++ .../20260609043906_InitialOrgBoard.cs | 132 +++++++++++ .../OrgBoardDbContextModelSnapshot.cs | 162 +++++++++++++ .../Persistence/OrgBoardDbContext.cs | 55 +++++ .../Persistence/OrgBoardDbContextFactory.cs | 21 ++ .../TeamUp.Modules.OrgBoard.csproj | 13 +- .../TeamUp.IntegrationTests/BoardFlowTests.cs | 139 +++++++++++ .../BootAndMigrateTests.cs | 3 +- .../IdentityFlowTests.cs | 3 +- .../PostgresFixture.cs | 6 - 18 files changed, 1137 insertions(+), 23 deletions(-) create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Domain/Enums.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Domain/Organization.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.Designer.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContextFactory.cs create mode 100644 tests/TeamUp.IntegrationTests/BoardFlowTests.cs diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Enums.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Enums.cs new file mode 100644 index 0000000..a2c5971 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Enums.cs @@ -0,0 +1,34 @@ +namespace TeamUp.Modules.OrgBoard.Domain; + +/// The seat-state triad — the load-bearing concept of the UI (human / open / AI). +internal enum SeatState +{ + Human, + Open, + Ai, +} + +internal enum WorkItemType +{ + Spec, + Story, + Test, + Review, + Release, +} + +/// The board columns: backlog → in progress → in review → done. +internal enum WorkItemStatus +{ + Backlog, + InProgress, + InReview, + Done, +} + +internal enum AssigneeKind +{ + Unassigned, + Member, + Agent, +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Organization.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Organization.cs new file mode 100644 index 0000000..6db0d2b --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Organization.cs @@ -0,0 +1,23 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// The company. Its id is the Organization scope that org-level memberships are granted at. +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; +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs new file mode 100644 index 0000000..6ddd707 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs @@ -0,0 +1,41 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// A role on a team, in one of three states: human / open / AI. AI seats are configured in M3. +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; + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs new file mode 100644 index 0000000..903df10 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs @@ -0,0 +1,22 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// A team within an organization. Team-level memberships are granted at its id (Team scope). +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; + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs new file mode 100644 index 0000000..31e793b --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs @@ -0,0 +1,64 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// A board task. Humans and AI share this one model — the assignee is a member or an agent. +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; + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs new file mode 100644 index 0000000..77933a1 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs @@ -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 Items); + +internal sealed record BoardResponse(Guid TeamId, IReadOnlyList Columns); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs new file mode 100644 index 0000000..8627648 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs @@ -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 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 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 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 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 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() + .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 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 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 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); + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs index 3ee2015..2d62eba 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs @@ -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(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + 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); } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.Designer.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.Designer.cs new file mode 100644 index 0000000..6e133a7 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.Designer.cs @@ -0,0 +1,165 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("seats", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("teams", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("AssigneeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.cs new file mode 100644 index 0000000..ab40ba6 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.cs @@ -0,0 +1,132 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.OrgBoard.Persistence.Migrations +{ + /// + public partial class InitialOrgBoard : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "orgboard"); + + migrationBuilder.CreateTable( + name: "organizations", + schema: "orgboard", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAtUtc = table.Column(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(type: "uuid", nullable: false), + TeamId = table.Column(type: "uuid", nullable: false), + RoleName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + MemberId = table.Column(type: "uuid", nullable: true), + AgentId = table.Column(type: "uuid", nullable: true), + CreatedAtUtc = table.Column(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(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAtUtc = table.Column(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(type: "uuid", nullable: false), + TeamId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + Description = table.Column(type: "text", nullable: true), + Type = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + AssigneeKind = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + AssigneeId = table.Column(type: "uuid", nullable: true), + ParentId = table.Column(type: "uuid", nullable: true), + CreatedByMemberId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs new file mode 100644 index 0000000..dd27b6e --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs @@ -0,0 +1,162 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("seats", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("teams", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("AssigneeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs new file mode 100644 index 0000000..298de91 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs @@ -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 options) + : DbContext(options), IModuleDbContext +{ + public DbSet Organizations => Set(); + public DbSet Teams => Set(); + public DbSet Seats => Set(); + public DbSet WorkItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("orgboard"); + + modelBuilder.Entity(organization => + { + organization.ToTable("organizations"); + organization.HasKey(o => o.Id); + organization.Property(o => o.Name).HasMaxLength(200).IsRequired(); + }); + + modelBuilder.Entity(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.ToTable("seats"); + seat.HasKey(s => s.Id); + seat.Property(s => s.RoleName).HasMaxLength(120).IsRequired(); + seat.Property(s => s.State).HasConversion().HasMaxLength(16); + seat.HasIndex(s => s.TeamId); + }); + + modelBuilder.Entity(workItem => + { + workItem.ToTable("work_items"); + workItem.HasKey(w => w.Id); + workItem.Property(w => w.Title).HasMaxLength(300).IsRequired(); + workItem.Property(w => w.Type).HasConversion().HasMaxLength(16); + workItem.Property(w => w.Status).HasConversion().HasMaxLength(16); + workItem.Property(w => w.AssigneeKind).HasConversion().HasMaxLength(16); + workItem.HasIndex(w => w.TeamId); + workItem.HasIndex(w => new { w.AssigneeKind, w.AssigneeId }); + }); + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContextFactory.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContextFactory.cs new file mode 100644 index 0000000..a0e2d23 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TeamUp.Modules.OrgBoard.Persistence; + +/// Design-time factory so `dotnet ef` can build the internal context without a host. +internal sealed class OrgBoardDbContextFactory : IDesignTimeDbContextFactory +{ + 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() + .UseNpgsql(connectionString) + .Options; + + return new OrgBoardDbContext(options); + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/TeamUp.Modules.OrgBoard.csproj b/src/Modules/TeamUp.Modules.OrgBoard/TeamUp.Modules.OrgBoard.csproj index 65f5856..79313eb 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/TeamUp.Modules.OrgBoard.csproj +++ b/src/Modules/TeamUp.Modules.OrgBoard/TeamUp.Modules.OrgBoard.csproj @@ -1,10 +1,17 @@ - + + + + + + + + diff --git a/tests/TeamUp.IntegrationTests/BoardFlowTests.cs b/tests/TeamUp.IntegrationTests/BoardFlowTests.cs new file mode 100644 index 0000000..7d4ab7e --- /dev/null +++ b/tests/TeamUp.IntegrationTests/BoardFlowTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// 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). +/// +public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture +{ + 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 Items); + + private sealed record BoardResponse(Guid TeamId, List 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(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(ownerClient, "/api/orgboard/teams", + new { organizationId = owner.OrganizationId, name = "IPNOPS" }); + var teams = await ownerClient.GetFromJsonAsync>( + $"/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(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(ownerClient, $"/api/orgboard/tasks/{task.Id}/move", + new { status = "InProgress" }); + Assert.Equal("InProgress", moved.Status); + + var assigned = await PatchOk(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($"/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>("/api/orgboard/cartable"); + Assert.Contains(cartable!, i => i.Id == task.Id); + + // Invite a Member at the org scope and accept. + var invite = await PostOk(ownerClient, "/api/identity/invitations", new + { + email = "dev@alia.test", + scopeType = "Organization", + scopeId = owner.OrganizationId, + role = "Member", + organizationId = owner.OrganizationId, + }); + var member = await PostOk(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 Bootstrap(HttpClient client) + { + var response = await PostOk(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 PostOk(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(); + Assert.NotNull(value); + return value!; + } + + private static async Task PatchOk(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(); + Assert.NotNull(value); + return value!; + } +} diff --git a/tests/TeamUp.IntegrationTests/BootAndMigrateTests.cs b/tests/TeamUp.IntegrationTests/BootAndMigrateTests.cs index ea94f62..a901fe3 100644 --- a/tests/TeamUp.IntegrationTests/BootAndMigrateTests.cs +++ b/tests/TeamUp.IntegrationTests/BootAndMigrateTests.cs @@ -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). /// -[Collection(PostgresCollection.Name)] -public sealed class BootAndMigrateTests(PostgresFixture postgres) +public sealed class BootAndMigrateTests(PostgresFixture postgres) : IClassFixture { private static readonly string[] ExpectedSchemas = ["identity", "orgboard", "skills", "integrations", "memory", "assembler", "governance"]; diff --git a/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs b/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs index 513d1bc..7828a8b 100644 --- a/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs +++ b/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs @@ -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. /// -[Collection(PostgresCollection.Name)] -public sealed class IdentityFlowTests(PostgresFixture postgres) +public sealed class IdentityFlowTests(PostgresFixture postgres) : IClassFixture { private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); diff --git a/tests/TeamUp.IntegrationTests/PostgresFixture.cs b/tests/TeamUp.IntegrationTests/PostgresFixture.cs index a9bf476..5e1ca23 100644 --- a/tests/TeamUp.IntegrationTests/PostgresFixture.cs +++ b/tests/TeamUp.IntegrationTests/PostgresFixture.cs @@ -19,9 +19,3 @@ public sealed class PostgresFixture : IAsyncLifetime public async ValueTask DisposeAsync() => await _container.DisposeAsync(); } - -[CollectionDefinition(Name)] -public sealed class PostgresCollection : ICollectionFixture -{ - public const string Name = "postgres"; -}