UI completion pass + accountability & benchmarking
UI (daily-drivable now): - Board: dnd-kit drag-and-drop between columns; click a card → task detail drawer (Sheet) with status, member assignee picker, send-to-AI-seat dispatch, description/artifact, parent/children navigation; seat-triad assignee chips (AI indigo monogram / human slate). - Cartable page (the personal pending slice), Members & invitations page (invite + copy join token; V1 sends no email), Review inbox now shows a word-level diff of your edits vs the proposal (lib/diff.ts, LCS), Org chart page (React Flow: org → teams → seats in the human/open/AI triad). Nav reordered; nothing left "soon". Accountability & benchmarking: - Identity: GET /members (directory + org role) and GET /invitations (with join token, inviter-only) — the directory also resolves names client-side everywhere. - OrgBoard: work_item_transitions recorded on every status change (AddWorkItemTransitions migration); GET /performance — per assignee (human and AI on the same scale): pending by column, done, worked hours (time in InProgress), avg cycle time (start of work → done), plus the unassigned-pending count. Owner-level capability. - Performance page: benchmark table merging board metrics with AI trust metrics (approval rate + edit distance from analytics); flags work with no one accountable. Verified: build green; ArchitectureTests 8/8; IntegrationTests 43/43 (new: directory, invitations list + Member 403s, transition-derived worked-hours/cycle-time, unassigned count); client npm build green (TS strict). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,3 +32,15 @@ internal sealed record InviteRequest(
|
||||
internal sealed record InviteResponse(Guid InvitationId, string Token);
|
||||
|
||||
internal sealed record AcceptInviteRequest(string Token, string DisplayName, string Password);
|
||||
|
||||
internal sealed record MemberRow(Guid Id, string Email, string DisplayName, string? Role);
|
||||
|
||||
internal sealed record InvitationRow(
|
||||
Guid Id,
|
||||
string Email,
|
||||
string ScopeType,
|
||||
Guid ScopeId,
|
||||
string Role,
|
||||
string Status,
|
||||
string Token,
|
||||
DateTimeOffset CreatedAtUtc);
|
||||
|
||||
@@ -23,10 +23,55 @@ internal static class IdentityEndpoints
|
||||
group.MapPost("/bootstrap", Bootstrap).AllowAnonymous();
|
||||
group.MapPost("/auth/login", Login).AllowAnonymous();
|
||||
group.MapGet("/me", Me).RequireAuthorization();
|
||||
group.MapGet("/members", ListMembers).RequireAuthorization();
|
||||
group.MapPost("/invitations", CreateInvitation).RequireAuthorization();
|
||||
group.MapGet("/invitations", ListInvitations).RequireAuthorization();
|
||||
group.MapPost("/invitations/accept", AcceptInvitation).AllowAnonymous();
|
||||
}
|
||||
|
||||
// The org directory, for assignment pickers and the members page. Any board viewer may read it.
|
||||
private static async Task<IResult> ListMembers(
|
||||
Guid organizationId, IPermissionService permissions, IdentityDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var members = await db.Members
|
||||
.OrderBy(m => m.CreatedAtUtc)
|
||||
.Select(m => new { m.Id, m.Email, m.DisplayName })
|
||||
.ToListAsync(ct);
|
||||
var orgRoles = await db.Memberships
|
||||
.Where(ms => ms.ScopeType == ScopeType.Organization && ms.ScopeId == organizationId)
|
||||
.ToDictionaryAsync(ms => ms.MemberId, ms => ms.Role.ToString(), ct);
|
||||
|
||||
return Results.Ok(members
|
||||
.Select(m => new MemberRow(m.Id, m.Email, m.DisplayName, orgRoles.GetValueOrDefault(m.Id)))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
// Pending/accepted invitations, for the members page. Inviter-level capability required;
|
||||
// the token is included so the inviter can copy the join link (V1 sends no email).
|
||||
private static async Task<IResult> ListInvitations(
|
||||
Guid organizationId, IPermissionService permissions, IdentityDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.InvitePeople, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var invitations = await db.Invitations
|
||||
.OrderByDescending(i => i.CreatedAtUtc)
|
||||
.Take(100)
|
||||
.Select(i => new InvitationRow(
|
||||
i.Id, i.Email, i.ScopeType.ToString(), i.ScopeId, i.Role.ToString(),
|
||||
i.Status.ToString(), i.Token, i.CreatedAtUtc))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(invitations);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Bootstrap(
|
||||
BootstrapRequest request,
|
||||
IdentityDbContext db,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// An immutable record of a board-status change. The raw material for accountability metrics:
|
||||
/// working hours (time in InProgress), cycle time (first InProgress → Done), and throughput.
|
||||
/// </summary>
|
||||
internal sealed class WorkItemTransition : Entity
|
||||
{
|
||||
public Guid WorkItemId { get; private set; }
|
||||
public Guid TeamId { get; private set; }
|
||||
public WorkItemStatus FromStatus { get; private set; }
|
||||
public WorkItemStatus ToStatus { get; private set; }
|
||||
public Guid? ActorMemberId { get; private set; }
|
||||
public DateTimeOffset OccurredAtUtc { get; private set; }
|
||||
|
||||
private WorkItemTransition()
|
||||
{
|
||||
}
|
||||
|
||||
public WorkItemTransition(
|
||||
Guid workItemId,
|
||||
Guid teamId,
|
||||
WorkItemStatus fromStatus,
|
||||
WorkItemStatus toStatus,
|
||||
Guid? actorMemberId,
|
||||
DateTimeOffset occurredAtUtc)
|
||||
{
|
||||
WorkItemId = workItemId;
|
||||
TeamId = teamId;
|
||||
FromStatus = fromStatus;
|
||||
ToStatus = toStatus;
|
||||
ActorMemberId = actorMemberId;
|
||||
OccurredAtUtc = occurredAtUtc;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ internal static class OrgBoardEndpoints
|
||||
group.MapGet("/seats", ListSeats).RequireAuthorization();
|
||||
group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization();
|
||||
group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization();
|
||||
|
||||
group.MapGet("/performance", PerformanceEndpoints.Get).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||
@@ -175,7 +177,15 @@ internal static class OrgBoardEndpoints
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
item!.MoveTo(request.Status, clock.GetUtcNow());
|
||||
var fromStatus = item!.Status;
|
||||
item.MoveTo(request.Status, clock.GetUtcNow());
|
||||
if (fromStatus != request.Status)
|
||||
{
|
||||
// The raw material for working-hours / cycle-time accountability metrics.
|
||||
db.Transitions.Add(new WorkItemTransition(
|
||||
item.Id, team.Id, fromStatus, request.Status, user.MemberId, clock.GetUtcNow()));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), ct);
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||
|
||||
internal sealed record PerformanceRow(
|
||||
string AssigneeKind,
|
||||
Guid AssigneeId,
|
||||
string? Name,
|
||||
int Backlog,
|
||||
int InProgress,
|
||||
int InReview,
|
||||
int Done,
|
||||
double WorkedHours,
|
||||
double? AvgCycleHours);
|
||||
|
||||
internal sealed record PerformanceResponse(int UnassignedPending, List<PerformanceRow> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Accountability metrics per assignee — human and AI on the same scale: pending load by column
|
||||
/// (who is accountable for what), working hours (time tasks spent InProgress, attributed to the
|
||||
/// current assignee), throughput (done), and avg cycle time (first InProgress → Done).
|
||||
/// </summary>
|
||||
internal static class PerformanceEndpoints
|
||||
{
|
||||
public static async Task<IResult> Get(
|
||||
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db,
|
||||
TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewAuditLog, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var teamIds = await db.Teams
|
||||
.Where(t => t.OrganizationId == organizationId)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var items = await db.WorkItems.Where(w => teamIds.Contains(w.TeamId)).ToListAsync(ct);
|
||||
var transitions = (await db.Transitions.Where(t => teamIds.Contains(t.TeamId)).ToListAsync(ct))
|
||||
.GroupBy(t => t.WorkItemId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.OccurredAtUtc).ToList());
|
||||
var agentNames = await db.Agents.ToDictionaryAsync(a => a.Id, a => a.Name, ct);
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var rows = items
|
||||
.Where(i => i.AssigneeKind != AssigneeKind.Unassigned && i.AssigneeId.HasValue)
|
||||
.GroupBy(i => (i.AssigneeKind, AssigneeId: i.AssigneeId!.Value))
|
||||
.Select(group =>
|
||||
{
|
||||
var byStatus = group.GroupBy(i => i.Status).ToDictionary(s => s.Key, s => s.Count());
|
||||
var workedHours = group.Sum(i => HoursInProgress(i, transitions, now));
|
||||
var cycles = group
|
||||
.Where(i => i.Status == WorkItemStatus.Done)
|
||||
.Select(i => CycleHours(i, transitions))
|
||||
.Where(h => h.HasValue)
|
||||
.Select(h => h!.Value)
|
||||
.ToList();
|
||||
|
||||
return new PerformanceRow(
|
||||
group.Key.AssigneeKind.ToString(),
|
||||
group.Key.AssigneeId,
|
||||
group.Key.AssigneeKind == AssigneeKind.Agent
|
||||
? agentNames.GetValueOrDefault(group.Key.AssigneeId)
|
||||
: null, // member names are joined client-side from /api/identity/members
|
||||
byStatus.GetValueOrDefault(WorkItemStatus.Backlog),
|
||||
byStatus.GetValueOrDefault(WorkItemStatus.InProgress),
|
||||
byStatus.GetValueOrDefault(WorkItemStatus.InReview),
|
||||
byStatus.GetValueOrDefault(WorkItemStatus.Done),
|
||||
Math.Round(workedHours, 2),
|
||||
cycles.Count == 0 ? null : Math.Round(cycles.Average(), 2));
|
||||
})
|
||||
.OrderByDescending(r => r.Done)
|
||||
.ToList();
|
||||
|
||||
var unassignedPending = items.Count(i =>
|
||||
i.AssigneeKind == AssigneeKind.Unassigned && i.Status != WorkItemStatus.Done);
|
||||
|
||||
return Results.Ok(new PerformanceResponse(unassignedPending, rows));
|
||||
}
|
||||
|
||||
/// <summary>Total hours the item has spent in InProgress (open span counts up to now).</summary>
|
||||
private static double HoursInProgress(
|
||||
WorkItem item,
|
||||
Dictionary<Guid, List<WorkItemTransition>> transitions,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (!transitions.TryGetValue(item.Id, out var list))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double hours = 0;
|
||||
DateTimeOffset? entered = null;
|
||||
foreach (var transition in list)
|
||||
{
|
||||
if (transition.ToStatus == WorkItemStatus.InProgress)
|
||||
{
|
||||
entered ??= transition.OccurredAtUtc;
|
||||
}
|
||||
else if (entered.HasValue && transition.FromStatus == WorkItemStatus.InProgress)
|
||||
{
|
||||
hours += (transition.OccurredAtUtc - entered.Value).TotalHours;
|
||||
entered = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (entered.HasValue)
|
||||
{
|
||||
hours += (now - entered.Value).TotalHours;
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/// <summary>First entry into InProgress (or creation) → the last transition to Done.</summary>
|
||||
private static double? CycleHours(
|
||||
WorkItem item,
|
||||
Dictionary<Guid, List<WorkItemTransition>> transitions)
|
||||
{
|
||||
if (!transitions.TryGetValue(item.Id, out var list))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var done = list.LastOrDefault(t => t.ToStatus == WorkItemStatus.Done);
|
||||
if (done is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var started = list.FirstOrDefault(t => t.ToStatus == WorkItemStatus.InProgress)?.OccurredAtUtc
|
||||
?? item.CreatedAtUtc;
|
||||
var hours = (done.OccurredAtUtc - started).TotalHours;
|
||||
return hours < 0 ? null : hours;
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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("20260610090933_AddWorkItemTransitions")]
|
||||
partial class AddWorkItemTransitions
|
||||
{
|
||||
/// <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.Agent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ApiConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Autonomy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Docs")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid?>("FallbackApiConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Monogram")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<Guid>("SeatId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeatId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("agents", "orgboard");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ActorMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FromStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ToStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("WorkItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.HasIndex("WorkItemId");
|
||||
|
||||
b.ToTable("work_item_transitions", "orgboard");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWorkItemTransitions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "work_item_transitions",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FromStatus = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
ToStatus = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
ActorMemberId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
OccurredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_work_item_transitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_work_item_transitions_TeamId",
|
||||
schema: "orgboard",
|
||||
table: "work_item_transitions",
|
||||
column: "TeamId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_work_item_transitions_WorkItemId",
|
||||
schema: "orgboard",
|
||||
table: "work_item_transitions",
|
||||
column: "WorkItemId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "work_item_transitions",
|
||||
schema: "orgboard");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -208,6 +208,43 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
|
||||
b.ToTable("work_items", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ActorMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FromStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ToStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("WorkItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.HasIndex("WorkItemId");
|
||||
|
||||
b.ToTable("work_item_transitions", "orgboard");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
public DbSet<Seat> Seats => Set<Seat>();
|
||||
public DbSet<Agent> Agents => Set<Agent>();
|
||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -62,5 +63,15 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
workItem.HasIndex(w => w.TeamId);
|
||||
workItem.HasIndex(w => new { w.AssigneeKind, w.AssigneeId });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WorkItemTransition>(transition =>
|
||||
{
|
||||
transition.ToTable("work_item_transitions");
|
||||
transition.HasKey(t => t.Id);
|
||||
transition.Property(t => t.FromStatus).HasConversion<string>().HasMaxLength(16);
|
||||
transition.Property(t => t.ToStatus).HasConversion<string>().HasMaxLength(16);
|
||||
transition.HasIndex(t => t.WorkItemId);
|
||||
transition.HasIndex(t => t.TeamId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user