M1: OrgBoard — organizations, teams, seats, the board & cartable
OrgBoard module (references SharedKernel only; RBAC via ICurrentUser/IPermissionService):
- Organization, Team, Seat (human/open/ai), WorkItem (board task: type, status, assignee,
parent) entities; internal OrgBoardDbContext (schema "orgboard") + InitialOrgBoard
migration; design-time factory. (WorkItem avoids the System.Threading.Tasks.Task clash.)
- Endpoints under /api/orgboard, every mutation permission-checked at the scope chain
[team, org]: POST /organizations, POST/GET /teams, POST /tasks, GET /board (columns
backlog->in progress->in review->done), PATCH /tasks/{id}/move, /assign, GET /cartable.
Test isolation: integration tests now use IClassFixture so each class gets its own
pgvector container (the bootstrap-once rule made a shared container collide).
Verified: build green; ArchitectureTests 8/8 (OrgBoard references only SharedKernel);
IntegrationTests 12/12 incl. a new board flow — owner sets up org+team, creates/moves/
assigns a task, sees it on the board and in the cartable; an invited Member can view the
board but is 403'd from creating a team.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>The seat-state triad — the load-bearing concept of the UI (human / open / AI).</summary>
|
||||
internal enum SeatState
|
||||
{
|
||||
Human,
|
||||
Open,
|
||||
Ai,
|
||||
}
|
||||
|
||||
internal enum WorkItemType
|
||||
{
|
||||
Spec,
|
||||
Story,
|
||||
Test,
|
||||
Review,
|
||||
Release,
|
||||
}
|
||||
|
||||
/// <summary>The board columns: backlog → in progress → in review → done.</summary>
|
||||
internal enum WorkItemStatus
|
||||
{
|
||||
Backlog,
|
||||
InProgress,
|
||||
InReview,
|
||||
Done,
|
||||
}
|
||||
|
||||
internal enum AssigneeKind
|
||||
{
|
||||
Unassigned,
|
||||
Member,
|
||||
Agent,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>The company. Its id is the Organization scope that org-level memberships are granted at.</summary>
|
||||
internal sealed class Organization : Entity
|
||||
{
|
||||
public string Name { get; private set; } = null!;
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
private Organization()
|
||||
{
|
||||
}
|
||||
|
||||
public Organization(Guid id, string name, DateTimeOffset createdAtUtc)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
|
||||
public void Rename(string name) => Name = name;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>A role on a team, in one of three states: human / open / AI. AI seats are configured in M3.</summary>
|
||||
internal sealed class Seat : Entity
|
||||
{
|
||||
public Guid TeamId { get; private set; }
|
||||
public string RoleName { get; private set; } = null!;
|
||||
public SeatState State { get; private set; }
|
||||
public Guid? MemberId { get; private set; }
|
||||
public Guid? AgentId { get; private set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
private Seat()
|
||||
{
|
||||
}
|
||||
|
||||
public Seat(Guid teamId, string roleName, SeatState state, DateTimeOffset createdAtUtc, Guid? memberId = null)
|
||||
{
|
||||
TeamId = teamId;
|
||||
RoleName = roleName;
|
||||
State = state;
|
||||
MemberId = memberId;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
|
||||
public void AssignMember(Guid memberId)
|
||||
{
|
||||
MemberId = memberId;
|
||||
AgentId = null;
|
||||
State = SeatState.Human;
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{
|
||||
MemberId = null;
|
||||
AgentId = null;
|
||||
State = SeatState.Open;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>A team within an organization. Team-level memberships are granted at its id (Team scope).</summary>
|
||||
internal sealed class Team : Entity
|
||||
{
|
||||
public Guid OrganizationId { get; private set; }
|
||||
public string Name { get; private set; } = null!;
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
private Team()
|
||||
{
|
||||
}
|
||||
|
||||
public Team(Guid organizationId, string name, DateTimeOffset createdAtUtc)
|
||||
{
|
||||
OrganizationId = organizationId;
|
||||
Name = name;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>A board task. Humans and AI share this one model — the assignee is a member or an agent.</summary>
|
||||
internal sealed class WorkItem : Entity
|
||||
{
|
||||
public Guid TeamId { get; private set; }
|
||||
public string Title { get; private set; } = null!;
|
||||
public string? Description { get; private set; }
|
||||
public WorkItemType Type { get; private set; }
|
||||
public WorkItemStatus Status { get; private set; }
|
||||
public AssigneeKind AssigneeKind { get; private set; }
|
||||
public Guid? AssigneeId { get; private set; }
|
||||
public Guid? ParentId { get; private set; }
|
||||
public Guid CreatedByMemberId { get; private set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; private set; }
|
||||
|
||||
private WorkItem()
|
||||
{
|
||||
}
|
||||
|
||||
public WorkItem(
|
||||
Guid teamId,
|
||||
string title,
|
||||
string? description,
|
||||
WorkItemType type,
|
||||
Guid createdByMemberId,
|
||||
DateTimeOffset nowUtc,
|
||||
Guid? parentId = null)
|
||||
{
|
||||
TeamId = teamId;
|
||||
Title = title;
|
||||
Description = description;
|
||||
Type = type;
|
||||
Status = WorkItemStatus.Backlog;
|
||||
AssigneeKind = AssigneeKind.Unassigned;
|
||||
CreatedByMemberId = createdByMemberId;
|
||||
ParentId = parentId;
|
||||
CreatedAtUtc = nowUtc;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void MoveTo(WorkItemStatus status, DateTimeOffset nowUtc)
|
||||
{
|
||||
Status = status;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void AssignToMember(Guid memberId, DateTimeOffset nowUtc)
|
||||
{
|
||||
AssigneeKind = AssigneeKind.Member;
|
||||
AssigneeId = memberId;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void Unassign(DateTimeOffset nowUtc)
|
||||
{
|
||||
AssigneeKind = AssigneeKind.Unassigned;
|
||||
AssigneeId = null;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||
|
||||
internal sealed record CreateOrganizationRequest(Guid OrganizationId, string Name);
|
||||
|
||||
internal sealed record OrganizationResponse(Guid Id, string Name);
|
||||
|
||||
internal sealed record CreateTeamRequest(Guid OrganizationId, string Name);
|
||||
|
||||
internal sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||
|
||||
internal sealed record CreateTaskRequest(Guid TeamId, string Title, string? Description, WorkItemType Type);
|
||||
|
||||
internal sealed record MoveTaskRequest(WorkItemStatus Status);
|
||||
|
||||
internal sealed record AssignTaskRequest(Guid MemberId);
|
||||
|
||||
internal sealed record TaskResponse(
|
||||
Guid Id,
|
||||
Guid TeamId,
|
||||
string Title,
|
||||
string? Description,
|
||||
string Type,
|
||||
string Status,
|
||||
string AssigneeKind,
|
||||
Guid? AssigneeId,
|
||||
Guid? ParentId);
|
||||
|
||||
internal sealed record BoardColumn(string Status, IReadOnlyList<TaskResponse> Items);
|
||||
|
||||
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> Columns);
|
||||
@@ -0,0 +1,223 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||
|
||||
internal static class OrgBoardEndpoints
|
||||
{
|
||||
public static void Map(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/orgboard").WithTags("OrgBoard");
|
||||
|
||||
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("orgboard")));
|
||||
group.MapPost("/organizations", CreateOrganization).RequireAuthorization();
|
||||
group.MapPost("/teams", CreateTeam).RequireAuthorization();
|
||||
group.MapGet("/teams", ListTeams).RequireAuthorization();
|
||||
group.MapPost("/tasks", CreateTask).RequireAuthorization();
|
||||
group.MapGet("/board", GetBoard).RequireAuthorization();
|
||||
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
|
||||
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
||||
group.MapGet("/cartable", Cartable).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||
item.Id, item.TeamId, item.Title, item.Description,
|
||||
item.Type.ToString(), item.Status.ToString(), item.AssigneeKind.ToString(),
|
||||
item.AssigneeId, item.ParentId);
|
||||
|
||||
private static async Task<IResult> CreateOrganization(
|
||||
CreateOrganizationRequest request, IPermissionService permissions,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest("Name is required.");
|
||||
}
|
||||
|
||||
var organization = await db.Organizations.FirstOrDefaultAsync(o => o.Id == request.OrganizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
organization = new Organization(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
||||
db.Organizations.Add(organization);
|
||||
}
|
||||
else
|
||||
{
|
||||
organization.Rename(request.Name.Trim());
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new OrganizationResponse(organization.Id, organization.Name));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTeam(
|
||||
CreateTeamRequest request, IPermissionService permissions,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest("Name is required.");
|
||||
}
|
||||
|
||||
if (!await db.Organizations.AnyAsync(o => o.Id == request.OrganizationId, ct))
|
||||
{
|
||||
return Results.BadRequest("Organization does not exist; create it first.");
|
||||
}
|
||||
|
||||
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTeams(
|
||||
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var teams = await db.Teams
|
||||
.Where(t => t.OrganizationId == organizationId)
|
||||
.OrderBy(t => t.CreatedAtUtc)
|
||||
.Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(teams);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTask(
|
||||
CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return Results.NotFound("Team not found.");
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return Results.BadRequest("Title is required.");
|
||||
}
|
||||
|
||||
var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
|
||||
db.WorkItems.Add(item);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBoard(
|
||||
Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return Results.NotFound("Team not found.");
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var items = await db.WorkItems.Where(w => w.TeamId == teamId).OrderBy(w => w.CreatedAtUtc).ToListAsync(ct);
|
||||
var columns = Enum.GetValues<WorkItemStatus>()
|
||||
.Select(status => new BoardColumn(
|
||||
status.ToString(),
|
||||
items.Where(i => i.Status == status).Select(ToResponse).ToList()))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new BoardResponse(teamId, columns));
|
||||
}
|
||||
|
||||
private static async Task<IResult> MoveTask(
|
||||
Guid id, MoveTaskRequest request, IPermissionService permissions,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
||||
if (error is not null)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team!.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
item!.MoveTo(request.Status, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AssignTask(
|
||||
Guid id, AssignTaskRequest request, IPermissionService permissions,
|
||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
||||
if (error is not null)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team!.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
item!.AssignToMember(request.MemberId, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
var memberId = user.MemberId;
|
||||
var items = await db.WorkItems
|
||||
.Where(w => w.AssigneeKind == AssigneeKind.Member && w.AssigneeId == memberId)
|
||||
.OrderByDescending(w => w.UpdatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
private static async Task<(WorkItem? Item, Team? Team, IResult? Error)> LoadItemWithTeam(
|
||||
OrgBoardDbContext db, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == itemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
return (null, null, Results.NotFound("Task not found."));
|
||||
}
|
||||
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == item.TeamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return (null, null, Results.NotFound("Team not found."));
|
||||
}
|
||||
|
||||
return (item, team, null);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.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);
|
||||
}
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(OrgBoardDbContext))]
|
||||
[Migration("20260609043906_InitialOrgBoard")]
|
||||
partial class InitialOrgBoard
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("orgboard")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("organizations", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AgentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("MemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RoleName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.ToTable("seats", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("teams", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssigneeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssigneeKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.HasIndex("AssigneeKind", "AssigneeId");
|
||||
|
||||
b.ToTable("work_items", "orgboard");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialOrgBoard : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "orgboard");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "organizations",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_organizations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "seats",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RoleName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
State = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
MemberId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
AgentId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_seats", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "teams",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_teams", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "work_items",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
Type = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
AssigneeKind = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
AssigneeId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedByMemberId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_work_items", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_seats_TeamId",
|
||||
schema: "orgboard",
|
||||
table: "seats",
|
||||
column: "TeamId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_teams_OrganizationId",
|
||||
schema: "orgboard",
|
||||
table: "teams",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_work_items_AssigneeKind_AssigneeId",
|
||||
schema: "orgboard",
|
||||
table: "work_items",
|
||||
columns: new[] { "AssigneeKind", "AssigneeId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_work_items_TeamId",
|
||||
schema: "orgboard",
|
||||
table: "work_items",
|
||||
column: "TeamId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "organizations",
|
||||
schema: "orgboard");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "seats",
|
||||
schema: "orgboard");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "teams",
|
||||
schema: "orgboard");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "work_items",
|
||||
schema: "orgboard");
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(OrgBoardDbContext))]
|
||||
partial class OrgBoardDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("orgboard")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("organizations", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AgentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("MemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RoleName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.ToTable("seats", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("teams", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssigneeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssigneeKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.HasIndex("AssigneeKind", "AssigneeId");
|
||||
|
||||
b.ToTable("work_items", "orgboard");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence;
|
||||
|
||||
internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> options)
|
||||
: DbContext(options), IModuleDbContext
|
||||
{
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<Team> Teams => Set<Team>();
|
||||
public DbSet<Seat> Seats => Set<Seat>();
|
||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("orgboard");
|
||||
|
||||
modelBuilder.Entity<Organization>(organization =>
|
||||
{
|
||||
organization.ToTable("organizations");
|
||||
organization.HasKey(o => o.Id);
|
||||
organization.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Team>(team =>
|
||||
{
|
||||
team.ToTable("teams");
|
||||
team.HasKey(t => t.Id);
|
||||
team.Property(t => t.Name).HasMaxLength(200).IsRequired();
|
||||
team.HasIndex(t => t.OrganizationId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Seat>(seat =>
|
||||
{
|
||||
seat.ToTable("seats");
|
||||
seat.HasKey(s => s.Id);
|
||||
seat.Property(s => s.RoleName).HasMaxLength(120).IsRequired();
|
||||
seat.Property(s => s.State).HasConversion<string>().HasMaxLength(16);
|
||||
seat.HasIndex(s => s.TeamId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WorkItem>(workItem =>
|
||||
{
|
||||
workItem.ToTable("work_items");
|
||||
workItem.HasKey(w => w.Id);
|
||||
workItem.Property(w => w.Title).HasMaxLength(300).IsRequired();
|
||||
workItem.Property(w => w.Type).HasConversion<string>().HasMaxLength(16);
|
||||
workItem.Property(w => w.Status).HasConversion<string>().HasMaxLength(16);
|
||||
workItem.Property(w => w.AssigneeKind).HasConversion<string>().HasMaxLength(16);
|
||||
workItem.HasIndex(w => w.TeamId);
|
||||
workItem.HasIndex(w => new { w.AssigneeKind, w.AssigneeId });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence;
|
||||
|
||||
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
|
||||
internal sealed class OrgBoardDbContextFactory : IDesignTimeDbContextFactory<OrgBoardDbContext>
|
||||
{
|
||||
public OrgBoardDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
|
||||
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
|
||||
|
||||
var options = new DbContextOptionsBuilder<OrgBoardDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new OrgBoardDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- 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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user