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";
-}