M1: audit log (Governance) + edit-distance metric
SharedKernel: - IAuditLog/AuditEvent — append-only audit contract any module writes through. - EditDistance (Levenshtein + normalized) — the north-star metric, available from day one; consumed at edit-and-approve in M5. Governance module (references SharedKernel only): - AuditEntry entity; internal GovernanceDbContext (schema "governance") + InitialGovernance migration; AuditLog implements IAuditLog. - GET /api/governance/audit — owner-only (ViewAuditLog), returns recent entries. Wiring (via the SharedKernel IAuditLog interface — no module references Governance): - OrgBoard records team.created, task.created, task.moved, task.assigned. - Identity records invitation.created, member.joined. Verified: build green; ArchitectureTests 8/8 (Governance references only SharedKernel; audit flows through the shared interface); IntegrationTests 20/20 — board flow now asserts task.created/task.moved appear in the audit log, plus EditDistance unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
using TeamUp.Modules.Governance.Domain;
|
||||||
|
using TeamUp.Modules.Governance.Persistence;
|
||||||
|
using TeamUp.SharedKernel.Auditing;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Auditing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes audit events to the governance store. Uses its own DbContext/transaction (best-effort,
|
||||||
|
/// decoupled from the acting module's unit of work) — sufficient for M1.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AuditLog(GovernanceDbContext db, TimeProvider clock) : IAuditLog
|
||||||
|
{
|
||||||
|
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
db.AuditEntries.Add(new AuditEntry(
|
||||||
|
auditEvent.Action,
|
||||||
|
auditEvent.EntityType,
|
||||||
|
auditEvent.EntityId,
|
||||||
|
auditEvent.ActorMemberId,
|
||||||
|
auditEvent.Details,
|
||||||
|
clock.GetUtcNow()));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Domain;
|
||||||
|
|
||||||
|
/// <summary>An immutable audit record. Append-only — never updated or deleted.</summary>
|
||||||
|
internal sealed class AuditEntry : Entity
|
||||||
|
{
|
||||||
|
public string Action { get; private set; } = null!;
|
||||||
|
public string EntityType { get; private set; } = null!;
|
||||||
|
public Guid EntityId { get; private set; }
|
||||||
|
public Guid? ActorMemberId { get; private set; }
|
||||||
|
public string? Details { get; private set; }
|
||||||
|
public DateTimeOffset OccurredAtUtc { get; private set; }
|
||||||
|
|
||||||
|
private AuditEntry()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuditEntry(
|
||||||
|
string action,
|
||||||
|
string entityType,
|
||||||
|
Guid entityId,
|
||||||
|
Guid? actorMemberId,
|
||||||
|
string? details,
|
||||||
|
DateTimeOffset occurredAtUtc)
|
||||||
|
{
|
||||||
|
Action = action;
|
||||||
|
EntityType = entityType;
|
||||||
|
EntityId = entityId;
|
||||||
|
ActorMemberId = actorMemberId;
|
||||||
|
Details = details;
|
||||||
|
OccurredAtUtc = occurredAtUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeamUp.Modules.Governance.Persistence;
|
||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Endpoints;
|
||||||
|
|
||||||
|
internal sealed record AuditEntryResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Action,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
Guid? ActorMemberId,
|
||||||
|
string? Details,
|
||||||
|
DateTimeOffset OccurredAtUtc);
|
||||||
|
|
||||||
|
internal static class GovernanceEndpoints
|
||||||
|
{
|
||||||
|
public static void Map(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
var group = endpoints.MapGroup("/api/governance").WithTags("Governance");
|
||||||
|
|
||||||
|
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance")));
|
||||||
|
group.MapGet("/audit", GetAudit).RequireAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAudit(
|
||||||
|
Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Owner-only. (M1 audit entries are not yet org-scoped — fine for single-org dogfood.)
|
||||||
|
if (!permissions.Has(Capability.ViewAuditLog, ScopeRef.Org(organizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var limit = Math.Clamp(take ?? 100, 1, 500);
|
||||||
|
var entries = await db.AuditEntries
|
||||||
|
.OrderByDescending(a => a.OccurredAtUtc)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(a => new AuditEntryResponse(
|
||||||
|
a.Id, a.Action, a.EntityType, a.EntityId, a.ActorMemberId, a.Details, a.OccurredAtUtc))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,32 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using TeamUp.Modules.Governance.Auditing;
|
||||||
|
using TeamUp.Modules.Governance.Endpoints;
|
||||||
|
using TeamUp.Modules.Governance.Persistence;
|
||||||
|
using TeamUp.SharedKernel.Auditing;
|
||||||
using TeamUp.SharedKernel.Modularity;
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
using TeamUp.SharedKernel.Persistence;
|
||||||
|
|
||||||
namespace TeamUp.Modules.Governance;
|
namespace TeamUp.Modules.Governance;
|
||||||
|
|
||||||
/// <summary>Autonomy dial, the action gate, the review inbox, the audit log (M5).</summary>
|
/// <summary>Autonomy dial, the action gate, the review inbox, the audit log (M5). M1 ships the audit log.</summary>
|
||||||
public sealed class GovernanceModule : IModule
|
public sealed class GovernanceModule : IModule
|
||||||
{
|
{
|
||||||
public string Name => "governance";
|
public string Name => "governance";
|
||||||
|
|
||||||
public void Register(IServiceCollection services, IConfiguration configuration)
|
public void Register(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Skeleton: no services yet. M5 introduces the action gate, ReviewItem context,
|
var connectionString = configuration.GetConnectionString("Postgres")
|
||||||
// edit-distance capture, and the immutable audit log here.
|
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
|
||||||
|
|
||||||
|
services.AddDbContext<GovernanceDbContext>(options => options.UseNpgsql(connectionString));
|
||||||
|
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<GovernanceDbContext>());
|
||||||
|
services.AddScoped<IAuditLog, AuditLog>();
|
||||||
|
services.TryAddSingleton(TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
public void MapEndpoints(IEndpointRouteBuilder endpoints) => GovernanceEndpoints.Map(endpoints);
|
||||||
{
|
|
||||||
endpoints.MapGroup($"/api/{Name}")
|
|
||||||
.WithTags("Governance")
|
|
||||||
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeamUp.Modules.Governance.Domain;
|
||||||
|
using TeamUp.SharedKernel.Persistence;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Persistence;
|
||||||
|
|
||||||
|
internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext> options)
|
||||||
|
: DbContext(options), IModuleDbContext
|
||||||
|
{
|
||||||
|
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema("governance");
|
||||||
|
|
||||||
|
modelBuilder.Entity<AuditEntry>(entry =>
|
||||||
|
{
|
||||||
|
entry.ToTable("audit_entries");
|
||||||
|
entry.HasKey(a => a.Id);
|
||||||
|
entry.Property(a => a.Action).HasMaxLength(100).IsRequired();
|
||||||
|
entry.Property(a => a.EntityType).HasMaxLength(100).IsRequired();
|
||||||
|
entry.Property(a => a.Details).HasMaxLength(2000);
|
||||||
|
entry.HasIndex(a => a.OccurredAtUtc);
|
||||||
|
entry.HasIndex(a => new { a.EntityType, a.EntityId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Persistence;
|
||||||
|
|
||||||
|
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
|
||||||
|
internal sealed class GovernanceDbContextFactory : IDesignTimeDbContextFactory<GovernanceDbContext>
|
||||||
|
{
|
||||||
|
public GovernanceDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var connectionString =
|
||||||
|
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
|
||||||
|
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<GovernanceDbContext>()
|
||||||
|
.UseNpgsql(connectionString)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new GovernanceDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
// <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.Governance.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(GovernanceDbContext))]
|
||||||
|
[Migration("20260609084417_InitialGovernance")]
|
||||||
|
partial class InitialGovernance
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("governance")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActorMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("EntityType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OccurredAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("EntityType", "EntityId");
|
||||||
|
|
||||||
|
b.ToTable("audit_entries", "governance");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialGovernance : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: "governance");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "audit_entries",
|
||||||
|
schema: "governance",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Action = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
EntityType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
EntityId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ActorMemberId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
Details = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||||
|
OccurredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_audit_entries", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_audit_entries_EntityType_EntityId",
|
||||||
|
schema: "governance",
|
||||||
|
table: "audit_entries",
|
||||||
|
columns: new[] { "EntityType", "EntityId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_audit_entries_OccurredAtUtc",
|
||||||
|
schema: "governance",
|
||||||
|
table: "audit_entries",
|
||||||
|
column: "OccurredAtUtc");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "audit_entries",
|
||||||
|
schema: "governance");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeamUp.Modules.Governance.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.Governance.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(GovernanceDbContext))]
|
||||||
|
partial class GovernanceDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("governance")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActorMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("EntityType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OccurredAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("EntityType", "EntityId");
|
||||||
|
|
||||||
|
b.ToTable("audit_entries", "governance");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
|
<!-- Autonomy, the action gate, the review inbox, and the audit log. In M1 it implements the
|
||||||
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
|
shared IAuditLog (append-only audit). References SharedKernel only. -->
|
||||||
gains an (internal) DbContext and validators. It must never reference another module. -->
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using TeamUp.Modules.Identity.Auth;
|
|||||||
using TeamUp.Modules.Identity.Domain;
|
using TeamUp.Modules.Identity.Domain;
|
||||||
using TeamUp.Modules.Identity.Persistence;
|
using TeamUp.Modules.Identity.Persistence;
|
||||||
using TeamUp.SharedKernel.Access;
|
using TeamUp.SharedKernel.Access;
|
||||||
|
using TeamUp.SharedKernel.Auditing;
|
||||||
using TeamUp.SharedKernel.Modularity;
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
|
||||||
namespace TeamUp.Modules.Identity.Endpoints;
|
namespace TeamUp.Modules.Identity.Endpoints;
|
||||||
@@ -101,6 +102,7 @@ internal static class IdentityEndpoints
|
|||||||
InviteRequest request,
|
InviteRequest request,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
IPermissionService permissions,
|
IPermissionService permissions,
|
||||||
|
IAuditLog audit,
|
||||||
IdentityDbContext db,
|
IdentityDbContext db,
|
||||||
TimeProvider clock,
|
TimeProvider clock,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -127,6 +129,8 @@ internal static class IdentityEndpoints
|
|||||||
|
|
||||||
db.Invitations.Add(invitation);
|
db.Invitations.Add(invitation);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(
|
||||||
|
new AuditEvent("invitation.created", "Invitation", invitation.Id, currentUser.MemberId, request.Email.Trim()), ct);
|
||||||
|
|
||||||
return Results.Ok(new InviteResponse(invitation.Id, token));
|
return Results.Ok(new InviteResponse(invitation.Id, token));
|
||||||
}
|
}
|
||||||
@@ -136,6 +140,7 @@ internal static class IdentityEndpoints
|
|||||||
IdentityDbContext db,
|
IdentityDbContext db,
|
||||||
IPasswordHasher<Member> hasher,
|
IPasswordHasher<Member> hasher,
|
||||||
JwtTokenService tokens,
|
JwtTokenService tokens,
|
||||||
|
IAuditLog audit,
|
||||||
TimeProvider clock,
|
TimeProvider clock,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -159,6 +164,7 @@ internal static class IdentityEndpoints
|
|||||||
db.Members.Add(member);
|
db.Members.Add(member);
|
||||||
db.Memberships.Add(membership);
|
db.Memberships.Add(membership);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(new AuditEvent("member.joined", "Member", member.Id, member.Id, member.Email), ct);
|
||||||
|
|
||||||
return Results.Ok(new AuthResponse(tokens.Issue(member, [membership]), member.Id));
|
return Results.Ok(new AuthResponse(tokens.Issue(member, [membership]), member.Id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeamUp.Modules.OrgBoard.Domain;
|
using TeamUp.Modules.OrgBoard.Domain;
|
||||||
using TeamUp.Modules.OrgBoard.Persistence;
|
using TeamUp.Modules.OrgBoard.Persistence;
|
||||||
using TeamUp.SharedKernel.Access;
|
using TeamUp.SharedKernel.Access;
|
||||||
|
using TeamUp.SharedKernel.Auditing;
|
||||||
using TeamUp.SharedKernel.Modularity;
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
|
||||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||||
@@ -61,8 +62,8 @@ internal static class OrgBoardEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateTeam(
|
private static async Task<IResult> CreateTeam(
|
||||||
CreateTeamRequest request, IPermissionService permissions,
|
CreateTeamRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
|
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
|
||||||
{
|
{
|
||||||
@@ -82,6 +83,7 @@ internal static class OrgBoardEndpoints
|
|||||||
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
||||||
db.Teams.Add(team);
|
db.Teams.Add(team);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct);
|
||||||
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
|
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ internal static class OrgBoardEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> CreateTask(
|
private static async Task<IResult> CreateTask(
|
||||||
CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
|
CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
|
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
|
||||||
if (team is null)
|
if (team is null)
|
||||||
@@ -125,6 +127,7 @@ internal static class OrgBoardEndpoints
|
|||||||
var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
|
var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
|
||||||
db.WorkItems.Add(item);
|
db.WorkItems.Add(item);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(new AuditEvent("task.created", "WorkItem", item.Id, user.MemberId, item.Title), ct);
|
||||||
return Results.Ok(ToResponse(item));
|
return Results.Ok(ToResponse(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +156,8 @@ internal static class OrgBoardEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> MoveTask(
|
private static async Task<IResult> MoveTask(
|
||||||
Guid id, MoveTaskRequest request, IPermissionService permissions,
|
Guid id, MoveTaskRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
@@ -169,12 +172,13 @@ internal static class OrgBoardEndpoints
|
|||||||
|
|
||||||
item!.MoveTo(request.Status, clock.GetUtcNow());
|
item!.MoveTo(request.Status, clock.GetUtcNow());
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), ct);
|
||||||
return Results.Ok(ToResponse(item));
|
return Results.Ok(ToResponse(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> AssignTask(
|
private static async Task<IResult> AssignTask(
|
||||||
Guid id, AssignTaskRequest request, IPermissionService permissions,
|
Guid id, AssignTaskRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
@@ -189,6 +193,7 @@ internal static class OrgBoardEndpoints
|
|||||||
|
|
||||||
item!.AssignToMember(request.MemberId, clock.GetUtcNow());
|
item!.AssignToMember(request.MemberId, clock.GetUtcNow());
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(new AuditEvent("task.assigned", "WorkItem", item.Id, user.MemberId, request.MemberId.ToString()), ct);
|
||||||
return Results.Ok(ToResponse(item));
|
return Results.Ok(ToResponse(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace TeamUp.SharedKernel.Auditing;
|
||||||
|
|
||||||
|
/// <summary>An immutable record of a meaningful action. Written by any module via <see cref="IAuditLog"/>.</summary>
|
||||||
|
public sealed record AuditEvent(
|
||||||
|
string Action,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
Guid? ActorMemberId,
|
||||||
|
string? Details = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The append-only audit log. Defined in SharedKernel and implemented by Governance, so any module
|
||||||
|
/// can record an action without referencing Governance.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuditLog
|
||||||
|
{
|
||||||
|
Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
namespace TeamUp.SharedKernel.Metrics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The product's north-star metric: how much a reviewer changes AI output before approving.
|
||||||
|
/// Levenshtein edit distance, available from day one (M1). It is consumed at edit-and-approve in
|
||||||
|
/// the review inbox (M5); kept here so the data path and computation exist before there is AI
|
||||||
|
/// output to measure.
|
||||||
|
/// </summary>
|
||||||
|
public static class EditDistance
|
||||||
|
{
|
||||||
|
/// <summary>Levenshtein distance — the minimum single-character edits to turn <paramref name="a"/> into <paramref name="b"/>.</summary>
|
||||||
|
public static int Levenshtein(string a, string b)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(a);
|
||||||
|
ArgumentNullException.ThrowIfNull(b);
|
||||||
|
|
||||||
|
if (a.Length == 0)
|
||||||
|
{
|
||||||
|
return b.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b.Length == 0)
|
||||||
|
{
|
||||||
|
return a.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = new int[b.Length + 1];
|
||||||
|
var current = new int[b.Length + 1];
|
||||||
|
|
||||||
|
for (var j = 0; j <= b.Length; j++)
|
||||||
|
{
|
||||||
|
previous[j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 1; i <= a.Length; i++)
|
||||||
|
{
|
||||||
|
current[0] = i;
|
||||||
|
for (var j = 1; j <= b.Length; j++)
|
||||||
|
{
|
||||||
|
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||||
|
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
(previous, current) = (current, previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
return previous[b.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Edit distance normalized to [0,1] by the longer string. 0 = unchanged (accepted as-is).</summary>
|
||||||
|
public static double Normalized(string original, string edited)
|
||||||
|
{
|
||||||
|
var max = Math.Max(original.Length, edited.Length);
|
||||||
|
return max == 0 ? 0d : (double)Levenshtein(original, edited) / max;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture<Pos
|
|||||||
|
|
||||||
private sealed record BoardResponse(Guid TeamId, List<BoardColumn> Columns);
|
private sealed record BoardResponse(Guid TeamId, List<BoardColumn> Columns);
|
||||||
|
|
||||||
|
private sealed record AuditEntryResponse(
|
||||||
|
Guid Id, string Action, string EntityType, Guid EntityId,
|
||||||
|
Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Owner_builds_board_and_member_is_scoped()
|
public async Task Owner_builds_board_and_member_is_scoped()
|
||||||
{
|
{
|
||||||
@@ -76,6 +80,12 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture<Pos
|
|||||||
var cartable = await ownerClient.GetFromJsonAsync<List<TaskResponse>>("/api/orgboard/cartable");
|
var cartable = await ownerClient.GetFromJsonAsync<List<TaskResponse>>("/api/orgboard/cartable");
|
||||||
Assert.Contains(cartable!, i => i.Id == task.Id);
|
Assert.Contains(cartable!, i => i.Id == task.Id);
|
||||||
|
|
||||||
|
// The audit log (owner-only) recorded the task actions.
|
||||||
|
var audit = await ownerClient.GetFromJsonAsync<List<AuditEntryResponse>>(
|
||||||
|
$"/api/governance/audit?organizationId={owner.OrganizationId}");
|
||||||
|
Assert.Contains(audit!, e => e.Action == "task.created" && e.EntityId == task.Id);
|
||||||
|
Assert.Contains(audit!, e => e.Action == "task.moved" && e.EntityId == task.Id);
|
||||||
|
|
||||||
// Invite a Member at the org scope and accept.
|
// Invite a Member at the org scope and accept.
|
||||||
var invite = await PostOk<InviteResponse>(ownerClient, "/api/identity/invitations", new
|
var invite = await PostOk<InviteResponse>(ownerClient, "/api/identity/invitations", new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using TeamUp.SharedKernel.Metrics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamUp.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Unit coverage for the north-star metric helper (no database).</summary>
|
||||||
|
public sealed class EditDistanceTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "", 0)]
|
||||||
|
[InlineData("abc", "abc", 0)]
|
||||||
|
[InlineData("", "abc", 3)]
|
||||||
|
[InlineData("abc", "", 3)]
|
||||||
|
[InlineData("kitten", "sitting", 3)]
|
||||||
|
[InlineData("spec v1", "spec v2", 1)]
|
||||||
|
public void Levenshtein_counts_edits(string a, string b, int expected) =>
|
||||||
|
Assert.Equal(expected, EditDistance.Levenshtein(a, b));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalized_is_zero_when_unchanged() =>
|
||||||
|
Assert.Equal(0d, EditDistance.Normalized("approved as-is", "approved as-is"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalized_is_between_zero_and_one() =>
|
||||||
|
Assert.InRange(EditDistance.Normalized("the AI wrote this", "the human edited this"), 0d, 1d);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user