diff --git a/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs b/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs new file mode 100644 index 0000000..c9432b7 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs @@ -0,0 +1,25 @@ +using TeamUp.Modules.Governance.Domain; +using TeamUp.Modules.Governance.Persistence; +using TeamUp.SharedKernel.Auditing; + +namespace TeamUp.Modules.Governance.Auditing; + +/// +/// 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. +/// +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); + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs b/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs new file mode 100644 index 0000000..777e853 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs @@ -0,0 +1,34 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.Governance.Domain; + +/// An immutable audit record. Append-only — never updated or deleted. +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; + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs new file mode 100644 index 0000000..bbde94a --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs @@ -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 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); + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs index c46f420..b7bccef 100644 --- a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs +++ b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs @@ -1,27 +1,32 @@ -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.Governance.Auditing; +using TeamUp.Modules.Governance.Endpoints; +using TeamUp.Modules.Governance.Persistence; +using TeamUp.SharedKernel.Auditing; using TeamUp.SharedKernel.Modularity; +using TeamUp.SharedKernel.Persistence; namespace TeamUp.Modules.Governance; -/// Autonomy dial, the action gate, the review inbox, the audit log (M5). +/// Autonomy dial, the action gate, the review inbox, the audit log (M5). M1 ships the audit log. public sealed class GovernanceModule : IModule { public string Name => "governance"; public void Register(IServiceCollection services, IConfiguration configuration) { - // Skeleton: no services yet. M5 introduces the action gate, ReviewItem context, - // edit-distance capture, and the immutable audit log here. + var connectionString = configuration.GetConnectionString("Postgres") + ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'."); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.TryAddSingleton(TimeProvider.System); } - public void MapEndpoints(IEndpointRouteBuilder endpoints) - { - endpoints.MapGroup($"/api/{Name}") - .WithTags("Governance") - .MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name))); - } + public void MapEndpoints(IEndpointRouteBuilder endpoints) => GovernanceEndpoints.Map(endpoints); } diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs new file mode 100644 index 0000000..637a598 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs @@ -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 options) + : DbContext(options), IModuleDbContext +{ + public DbSet AuditEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("governance"); + + modelBuilder.Entity(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 }); + }); + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs new file mode 100644 index 0000000..c480e93 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TeamUp.Modules.Governance.Persistence; + +/// Design-time factory so `dotnet ef` can build the internal context without a host. +internal sealed class GovernanceDbContextFactory : IDesignTimeDbContextFactory +{ + 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() + .UseNpgsql(connectionString) + .Options; + + return new GovernanceDbContext(options); + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs new file mode 100644 index 0000000..07d0a63 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs @@ -0,0 +1,69 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorMemberId") + .HasColumnType("uuid"); + + b.Property("Details") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs new file mode 100644 index 0000000..c391468 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.Governance.Persistence.Migrations +{ + /// + public partial class InitialGovernance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "governance"); + + migrationBuilder.CreateTable( + name: "audit_entries", + schema: "governance", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Action = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EntityType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EntityId = table.Column(type: "uuid", nullable: false), + ActorMemberId = table.Column(type: "uuid", nullable: true), + Details = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + OccurredAtUtc = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "audit_entries", + schema: "governance"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs new file mode 100644 index 0000000..e3ce041 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorMemberId") + .HasColumnType("uuid"); + + b.Property("Details") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj b/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj index 65f5856..58a70e9 100644 --- a/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj +++ b/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj @@ -1,10 +1,15 @@ - + + + + + + + diff --git a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs index d11d887..32639f9 100644 --- a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs +++ b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs @@ -8,6 +8,7 @@ using TeamUp.Modules.Identity.Auth; using TeamUp.Modules.Identity.Domain; using TeamUp.Modules.Identity.Persistence; using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Auditing; using TeamUp.SharedKernel.Modularity; namespace TeamUp.Modules.Identity.Endpoints; @@ -101,6 +102,7 @@ internal static class IdentityEndpoints InviteRequest request, ICurrentUser currentUser, IPermissionService permissions, + IAuditLog audit, IdentityDbContext db, TimeProvider clock, CancellationToken ct) @@ -127,6 +129,8 @@ internal static class IdentityEndpoints db.Invitations.Add(invitation); 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)); } @@ -136,6 +140,7 @@ internal static class IdentityEndpoints IdentityDbContext db, IPasswordHasher hasher, JwtTokenService tokens, + IAuditLog audit, TimeProvider clock, CancellationToken ct) { @@ -159,6 +164,7 @@ internal static class IdentityEndpoints db.Members.Add(member); db.Memberships.Add(membership); 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)); } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs index 8627648..aa46d03 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using TeamUp.Modules.OrgBoard.Domain; using TeamUp.Modules.OrgBoard.Persistence; using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Auditing; using TeamUp.SharedKernel.Modularity; namespace TeamUp.Modules.OrgBoard.Endpoints; @@ -61,8 +62,8 @@ internal static class OrgBoardEndpoints } private static async Task CreateTeam( - CreateTeamRequest request, IPermissionService permissions, - OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + CreateTeamRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) { 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()); db.Teams.Add(team); 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)); } @@ -104,7 +106,7 @@ internal static class OrgBoardEndpoints private static async Task CreateTask( 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); 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()); db.WorkItems.Add(item); await db.SaveChangesAsync(ct); + await audit.WriteAsync(new AuditEvent("task.created", "WorkItem", item.Id, user.MemberId, item.Title), ct); return Results.Ok(ToResponse(item)); } @@ -153,8 +156,8 @@ internal static class OrgBoardEndpoints } private static async Task MoveTask( - Guid id, MoveTaskRequest request, IPermissionService permissions, - OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + Guid id, MoveTaskRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) { var (item, team, error) = await LoadItemWithTeam(db, id, ct); if (error is not null) @@ -169,12 +172,13 @@ internal static class OrgBoardEndpoints item!.MoveTo(request.Status, clock.GetUtcNow()); 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)); } private static async Task AssignTask( - Guid id, AssignTaskRequest request, IPermissionService permissions, - OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + Guid id, AssignTaskRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) { var (item, team, error) = await LoadItemWithTeam(db, id, ct); if (error is not null) @@ -189,6 +193,7 @@ internal static class OrgBoardEndpoints item!.AssignToMember(request.MemberId, clock.GetUtcNow()); 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)); } diff --git a/src/Shared/TeamUp.SharedKernel/Auditing/IAuditLog.cs b/src/Shared/TeamUp.SharedKernel/Auditing/IAuditLog.cs new file mode 100644 index 0000000..b77306f --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Auditing/IAuditLog.cs @@ -0,0 +1,18 @@ +namespace TeamUp.SharedKernel.Auditing; + +/// An immutable record of a meaningful action. Written by any module via . +public sealed record AuditEvent( + string Action, + string EntityType, + Guid EntityId, + Guid? ActorMemberId, + string? Details = null); + +/// +/// The append-only audit log. Defined in SharedKernel and implemented by Governance, so any module +/// can record an action without referencing Governance. +/// +public interface IAuditLog +{ + Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default); +} diff --git a/src/Shared/TeamUp.SharedKernel/Metrics/EditDistance.cs b/src/Shared/TeamUp.SharedKernel/Metrics/EditDistance.cs new file mode 100644 index 0000000..162857a --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Metrics/EditDistance.cs @@ -0,0 +1,56 @@ +namespace TeamUp.SharedKernel.Metrics; + +/// +/// 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. +/// +public static class EditDistance +{ + /// Levenshtein distance — the minimum single-character edits to turn into . + 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]; + } + + /// Edit distance normalized to [0,1] by the longer string. 0 = unchanged (accepted as-is). + 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; + } +} diff --git a/tests/TeamUp.IntegrationTests/BoardFlowTests.cs b/tests/TeamUp.IntegrationTests/BoardFlowTests.cs index 7d4ab7e..0471f57 100644 --- a/tests/TeamUp.IntegrationTests/BoardFlowTests.cs +++ b/tests/TeamUp.IntegrationTests/BoardFlowTests.cs @@ -30,6 +30,10 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture Columns); + private sealed record AuditEntryResponse( + Guid Id, string Action, string EntityType, Guid EntityId, + Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc); + [Fact] public async Task Owner_builds_board_and_member_is_scoped() { @@ -76,6 +80,12 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture>("/api/orgboard/cartable"); Assert.Contains(cartable!, i => i.Id == task.Id); + // The audit log (owner-only) recorded the task actions. + var audit = await ownerClient.GetFromJsonAsync>( + $"/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. var invite = await PostOk(ownerClient, "/api/identity/invitations", new { diff --git a/tests/TeamUp.IntegrationTests/EditDistanceTests.cs b/tests/TeamUp.IntegrationTests/EditDistanceTests.cs new file mode 100644 index 0000000..d14d107 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/EditDistanceTests.cs @@ -0,0 +1,26 @@ +using TeamUp.SharedKernel.Metrics; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// Unit coverage for the north-star metric helper (no database). +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); +}