From bcdbc7e941e1d6b310dfc96a074ef7b24d0309d1 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 15 Jun 2026 20:40:57 +0330 Subject: [PATCH] =?UTF-8?q?Versioned=20PRODUCT.md=20library=20+=20marketpl?= =?UTF-8?q?ace=20=E2=80=94=20backend=20(Slice=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the agent-profile stack for products: ProductProfile entity (org-scoped, versioned by org+key+version; null org = free builtin), a PRODUCT.md parser + writer, and endpoints — upload, list, marketplace, get, publish/unpublish, fork, install, and apply-to-product (sets Product.Identity to the profile's PRODUCT.md). Reuses the shared ProfileOrigin/Status/Visibility enums; product profiles are gated owner-level (CreateProductsAndTeams). Adds the product_profiles table. Co-Authored-By: Claude Opus 4.8 --- .../Domain/ProductProfile.cs | 84 +++ .../Endpoints/OrgBoardDtos.cs | 25 + .../Endpoints/OrgBoardEndpoints.cs | 1 + .../Endpoints/ProductProfileEndpoints.cs | 329 ++++++++++++ .../TeamUp.Modules.OrgBoard/OrgBoardModule.cs | 1 + ...60615170931_AddProductProfiles.Designer.cs | 488 ++++++++++++++++++ .../20260615170931_AddProductProfiles.cs | 62 +++ .../OrgBoardDbContextModelSnapshot.cs | 73 +++ .../Persistence/OrgBoardDbContext.cs | 19 + .../Profiles/ProductProfileManifest.cs | 13 + .../Profiles/ProductProfileMarkdownParser.cs | 65 +++ .../Profiles/ProductProfileWriter.cs | 48 ++ 12 files changed, 1208 insertions(+) create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Domain/ProductProfile.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Endpoints/ProductProfileEndpoints.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.Designer.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileManifest.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileMarkdownParser.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileWriter.cs diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/ProductProfile.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/ProductProfile.cs new file mode 100644 index 0000000..7287ebc --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/ProductProfile.cs @@ -0,0 +1,84 @@ +using TeamUp.Modules.OrgBoard.Profiles; +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// +/// A reusable product identity, authored as a PRODUCT.md (YAML frontmatter + a Markdown body that is +/// the product's brief). Mirrors the agent-profile / skill library: org-scoped and versioned by +/// (OrganizationId, ProfileKey, Version); a null org is a free, shared builtin; publishing lists it on +/// the marketplace, where other orgs install a private copy. Applying a profile sets a product's identity. +/// +internal sealed class ProductProfile : Entity +{ + /// Owning org. Null = a free shared builtin. + public Guid? OrganizationId { get; private set; } + public ProfileOrigin Origin { get; private set; } + public Guid? AuthoredByMemberId { get; private set; } + public string ProfileKey { get; private set; } = null!; + public string Name { get; private set; } = null!; + public string Version { get; private set; } = null!; + public string? Summary { get; private set; } + public string Body { get; private set; } = null!; + public ProfileVisibility Visibility { get; private set; } + public ProfileStatus Status { get; private set; } + public string ContentHash { get; private set; } = null!; + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + + private ProductProfile() + { + } + + public static ProductProfile Create(string profileKey, string version, Guid? organizationId, DateTimeOffset nowUtc) => + new() { ProfileKey = profileKey, Version = version, OrganizationId = organizationId, CreatedAtUtc = nowUtc }; + + /// (Re)projects a parsed manifest + body onto this row. Used for both insert and update. + public void Apply( + ProductProfileManifest manifest, + string body, + string contentHash, + ProfileOrigin origin, + Guid? authoredByMemberId, + DateTimeOffset nowUtc) + { + Origin = origin; + AuthoredByMemberId = authoredByMemberId; + Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name; + Summary = manifest.Summary; + Body = body; + ContentHash = contentHash; + + // Publish gate (structural): a product profile is published once it is named and carries a + // non-empty brief. Only a Published profile may be Public. + Status = !string.IsNullOrWhiteSpace(body) ? ProfileStatus.Published : ProfileStatus.Draft; + Visibility = Status == ProfileStatus.Published ? ParseVisibility(manifest.Visibility) : ProfileVisibility.PrivateToOrg; + UpdatedAtUtc = nowUtc; + } + + /// Lists/unlists this version on the marketplace. Listing requires a Published profile. + public void SetVisibility(ProfileVisibility visibility, DateTimeOffset nowUtc) + { + Visibility = visibility == ProfileVisibility.Public && Status != ProfileStatus.Published + ? ProfileVisibility.PrivateToOrg + : visibility; + UpdatedAtUtc = nowUtc; + } + + /// Reconstruct the PRODUCT.md (frontmatter + body) to store as a product's identity. + public string ToMarkdown() + { + var lines = new List { $"product: {Name}", $"version: {Version}" }; + if (!string.IsNullOrWhiteSpace(Summary)) + { + lines.Add($"summary: {Summary}"); + } + + return $"---\n{string.Join('\n', lines)}\n---\n\n{Body}"; + } + + private static ProfileVisibility ParseVisibility(string value) => + value.Trim().Replace("-", string.Empty).Replace("_", string.Empty).ToLowerInvariant() is "privatetoorg" or "private" + ? ProfileVisibility.PrivateToOrg + : ProfileVisibility.Public; +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs index af0e65d..e402da6 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs @@ -100,3 +100,28 @@ internal sealed record AgentProfileSummary( internal sealed record AgentProfileDetail(AgentProfileSummary Profile, string Body); internal sealed record MarketplaceProfileEntry(AgentProfileSummary Profile, bool AlreadyInLibrary); + +internal sealed record UploadProductProfileRequest(Guid OrganizationId, string Content); + +internal sealed record PublishProductProfileRequest(Guid OrganizationId, string Version); + +internal sealed record ForkProductProfileRequest(Guid OrganizationId, string Version, string? Name = null); + +internal sealed record InstallProductProfileRequest(Guid OrganizationId, Guid SourceProfileId); + +internal sealed record ApplyProductProfileRequest(Guid OrganizationId, Guid ProductId, string Version); + +internal sealed record ProductProfileSummary( + Guid Id, + Guid? OrganizationId, + string Origin, + string ProfileKey, + string Name, + string Version, + string? Summary, + string Visibility, + string Status); + +internal sealed record ProductProfileDetail(ProductProfileSummary Profile, string Body); + +internal sealed record MarketplaceProductProfileEntry(ProductProfileSummary Profile, bool AlreadyInLibrary); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs index 35da7c4..bdac069 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs @@ -40,6 +40,7 @@ internal static class OrgBoardEndpoints group.MapGet("/performance", PerformanceEndpoints.Get).RequireAuthorization(); AgentProfileEndpoints.MapTo(group); + ProductProfileEndpoints.MapTo(group); } private static TaskResponse ToResponse(WorkItem item) => new( diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/ProductProfileEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/ProductProfileEndpoints.cs new file mode 100644 index 0000000..c94fa78 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/ProductProfileEndpoints.cs @@ -0,0 +1,329 @@ +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.Modules.OrgBoard.Profiles; +using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Auditing; + +namespace TeamUp.Modules.OrgBoard.Endpoints; + +/// +/// The product-profile library (PRODUCT.md): a company authors reusable product identities, versions +/// them, and applies them to a product; free builtins ship for everyone; publishing lists a profile on +/// the marketplace, where other orgs install a private copy. Mirrors the agent-profile library. +/// +internal static class ProductProfileEndpoints +{ + public static void MapTo(RouteGroupBuilder group) + { + group.MapPost("/product-profiles/upload", Upload).RequireAuthorization(); + group.MapGet("/product-profiles", List).RequireAuthorization(); + group.MapGet("/product-profiles/marketplace", Marketplace).RequireAuthorization(); + group.MapGet("/product-profiles/{key}", Get).RequireAuthorization(); + group.MapPost("/product-profiles/{key}/publish", Publish).RequireAuthorization(); + group.MapPost("/product-profiles/{key}/unpublish", Unpublish).RequireAuthorization(); + group.MapPost("/product-profiles/{key}/fork", Fork).RequireAuthorization(); + group.MapPost("/product-profiles/{key}/apply", Apply).RequireAuthorization(); + group.MapPost("/product-profiles/install", Install).RequireAuthorization(); + } + + // Upload a custom PRODUCT.md → an org-owned Authored profile (private until published). + private static async Task Upload( + UploadProductProfileRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, ProductProfileWriter writer, CancellationToken ct) + { + if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId))) + { + return Results.Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.Content)) + { + return Results.BadRequest("content is required."); + } + + ParsedProductProfile parsed; + try + { + parsed = ProductProfileMarkdownParser.Parse(request.Content); + } + catch (FormatException ex) + { + return Results.BadRequest(ex.Message); + } + + var profile = await writer.UpsertAsync( + parsed.Manifest, parsed.Body, request.OrganizationId, ProfileOrigin.Authored, user.MemberId, cancellationToken: ct); + await audit.WriteAsync( + new AuditEvent("product-profile.uploaded", "ProductProfile", profile.Id, user.MemberId, $"{profile.ProfileKey}@{profile.Version}"), ct); + return Results.Ok(ToDetail(profile)); + } + + // The library a company sees = the free shared builtins (null org) + its own profiles. + private static async Task List( + Guid? organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) + { + IQueryable query = db.ProductProfiles.Where(p => p.OrganizationId == null); + if (organizationId is { } orgId) + { + if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId))) + { + return Results.Forbid(); + } + + query = db.ProductProfiles.Where(p => p.OrganizationId == null || p.OrganizationId == orgId); + } + + // Order so the FIRST row per key is the one that resolves: Published over Draft, the org's own + // over the shared builtin, then the latest version (Ordinal). + var profiles = (await query.ToListAsync(ct)) + .OrderBy(p => p.ProfileKey, StringComparer.Ordinal) + .ThenByDescending(p => p.Status == ProfileStatus.Published) + .ThenByDescending(p => p.OrganizationId == organizationId) + .ThenByDescending(p => p.Version, StringComparer.Ordinal) + .ToList(); + + return Results.Ok(profiles.Select(ToSummary).ToList()); + } + + // The marketplace: published profiles other orgs have listed publicly. Excludes your own and flags + // any (key, version) already in your library. + private static async Task Marketplace( + Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) + { + if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId))) + { + return Results.Forbid(); + } + + var listed = await db.ProductProfiles + .Where(p => p.Origin == ProfileOrigin.Authored + && p.Visibility == ProfileVisibility.Public + && p.Status == ProfileStatus.Published + && p.OrganizationId != null + && p.OrganizationId != organizationId) + .OrderBy(p => p.ProfileKey) + .ThenByDescending(p => p.Version) + .ToListAsync(ct); + + var owned = (await db.ProductProfiles + .Where(p => p.OrganizationId == organizationId) + .Select(p => new { p.ProfileKey, p.Version }) + .ToListAsync(ct)) + .Select(p => (p.ProfileKey, p.Version)) + .ToHashSet(); + + return Results.Ok(listed + .Select(p => new MarketplaceProductProfileEntry(ToSummary(p), owned.Contains((p.ProfileKey, p.Version)))) + .ToList()); + } + + private static async Task Get( + string key, Guid? organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) + { + if (organizationId is { } orgId && !permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId))) + { + return Results.Forbid(); + } + + var versions = await db.ProductProfiles + .Where(p => p.ProfileKey == key && (p.OrganizationId == null || p.OrganizationId == organizationId)) + .OrderByDescending(p => p.OrganizationId != null) + .ThenByDescending(p => p.Version) + .ToListAsync(ct); + + return versions.Count == 0 ? Results.NotFound() : Results.Ok(versions.Select(ToDetail).ToList()); + } + + private static async Task Publish( + string key, PublishProductProfileRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + { + if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId))) + { + return Results.Forbid(); + } + + var profile = await db.ProductProfiles.FirstOrDefaultAsync( + p => p.OrganizationId == request.OrganizationId && p.ProfileKey == key && p.Version == request.Version, ct); + if (profile is null) + { + return Results.NotFound(); + } + + if (profile.Status != ProfileStatus.Published) + { + return Results.BadRequest("Only a complete profile (named, with a brief) can be listed."); + } + + profile.SetVisibility(ProfileVisibility.Public, clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + await audit.WriteAsync( + new AuditEvent("product-profile.published", "ProductProfile", profile.Id, user.MemberId, $"{profile.ProfileKey}@{profile.Version}"), ct); + return Results.Ok(ToDetail(profile)); + } + + private static async Task Unpublish( + string key, PublishProductProfileRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + { + if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId))) + { + return Results.Forbid(); + } + + var profile = await db.ProductProfiles.FirstOrDefaultAsync( + p => p.OrganizationId == request.OrganizationId && p.ProfileKey == key && p.Version == request.Version, ct); + if (profile is null) + { + return Results.NotFound(); + } + + profile.SetVisibility(ProfileVisibility.PrivateToOrg, clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + await audit.WriteAsync( + new AuditEvent("product-profile.unpublished", "ProductProfile", profile.Id, user.MemberId, $"{profile.ProfileKey}@{profile.Version}"), ct); + return Results.Ok(ToDetail(profile)); + } + + // Fork a builtin (or the org's own) version into an editable, org-owned Authored copy. + private static async Task Fork( + string key, ForkProductProfileRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, ProductProfileWriter writer, CancellationToken ct) + { + if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId))) + { + return Results.Forbid(); + } + + var source = await db.ProductProfiles.FirstOrDefaultAsync( + p => p.ProfileKey == key && p.Version == request.Version + && (p.OrganizationId == null || p.OrganizationId == request.OrganizationId), + ct); + if (source is null) + { + return Results.NotFound(); + } + + var manifest = ToManifest(source); + if (!string.IsNullOrWhiteSpace(request.Name)) + { + manifest.Name = request.Name.Trim(); + } + + var profile = await writer.UpsertAsync( + manifest, source.Body, request.OrganizationId, ProfileOrigin.Authored, user.MemberId, cancellationToken: ct); + await audit.WriteAsync( + new AuditEvent("product-profile.forked", "ProductProfile", profile.Id, user.MemberId, $"{profile.ProfileKey}@{profile.Version}"), ct); + return Results.Ok(ToDetail(profile)); + } + + // Apply a profile to a product: set the product's shared identity to the profile's PRODUCT.md. + private static async Task Apply( + string key, ApplyProductProfileRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, CancellationToken ct) + { + if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId))) + { + return Results.Forbid(); + } + + var profile = await db.ProductProfiles.FirstOrDefaultAsync( + p => p.ProfileKey == key && p.Version == request.Version + && (p.OrganizationId == null || p.OrganizationId == request.OrganizationId), + ct); + if (profile is null) + { + return Results.NotFound(); + } + + var product = await db.Products.FirstOrDefaultAsync( + p => p.Id == request.ProductId && p.OrganizationId == request.OrganizationId, ct); + if (product is null) + { + return Results.BadRequest("Product not found in this organization."); + } + + product.SetIdentity(profile.ToMarkdown()); + await db.SaveChangesAsync(ct); + await audit.WriteAsync( + new AuditEvent("product-profile.applied", "Product", product.Id, user.MemberId, $"{profile.ProfileKey}@{profile.Version}"), ct); + return Results.Ok(ToDetail(profile)); + } + + // Copy a publicly-listed profile into the caller's org as a private Installed copy. + private static async Task Install( + InstallProductProfileRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, ProductProfileWriter writer, CancellationToken ct) + { + if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId))) + { + return Results.Forbid(); + } + + var source = await db.ProductProfiles.FirstOrDefaultAsync(p => p.Id == request.SourceProfileId, ct); + if (source is null) + { + return Results.NotFound(); + } + + if (source.Origin != ProfileOrigin.Authored + || source.Visibility != ProfileVisibility.Public + || source.Status != ProfileStatus.Published) + { + return Results.BadRequest("That profile is not published to the marketplace."); + } + + if (source.OrganizationId == request.OrganizationId) + { + return Results.BadRequest("That profile already belongs to your organization."); + } + + if (await db.ProductProfiles.AnyAsync( + p => p.OrganizationId == request.OrganizationId && p.ProfileKey == source.ProfileKey && p.Version == source.Version, ct)) + { + return Results.Conflict("This profile version is already in your library."); + } + + var manifest = ToManifest(source); + manifest.Visibility = "private"; + try + { + var profile = await writer.UpsertAsync( + manifest, source.Body, request.OrganizationId, ProfileOrigin.Installed, user.MemberId, insertOnly: true, cancellationToken: ct); + await audit.WriteAsync( + new AuditEvent("product-profile.installed", "ProductProfile", profile.Id, user.MemberId, $"{profile.ProfileKey}@{profile.Version}"), ct); + return Results.Ok(ToDetail(profile)); + } + catch (DbUpdateException) + { + return Results.Conflict("This profile version is already in your library."); + } + } + + private static ProductProfileManifest ToManifest(ProductProfile profile) => new() + { + Id = profile.ProfileKey, + Product = profile.Name, + Name = profile.Name, + Version = profile.Version, + Summary = profile.Summary, + Visibility = profile.Visibility.ToString(), + }; + + private static ProductProfileSummary ToSummary(ProductProfile profile) => new( + profile.Id, + profile.OrganizationId, + profile.Origin.ToString(), + profile.ProfileKey, + profile.Name, + profile.Version, + profile.Summary, + profile.Visibility.ToString(), + profile.Status.ToString()); + + private static ProductProfileDetail ToDetail(ProductProfile profile) => new(ToSummary(profile), profile.Body); +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs index d52584a..de240ef 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs @@ -32,6 +32,7 @@ public sealed class OrgBoardModule : IModule services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.TryAddSingleton(TimeProvider.System); } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.Designer.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.Designer.cs new file mode 100644 index 0000000..af9c1d4 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.Designer.cs @@ -0,0 +1,488 @@ +// +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("20260615170931_AddProductProfiles")] + partial class AddProductProfiles + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiConfigId") + .HasColumnType("uuid"); + + b.Property("Autonomy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Docs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FallbackApiConfigId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("McpServerIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("Monogram") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Persona") + .HasColumnType("text"); + + b.Property("SeatId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("SkillKeys") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SeatId") + .IsUnique(); + + b.ToTable("agents", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.AgentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthoredByMemberId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Monogram") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Origin") + .HasColumnType("integer"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RecommendedAutonomy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("SkillKeys") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OrganizationId", "ProfileKey", "Version") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false); + + b.ToTable("agent_profiles", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("divisions", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("organizations", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DivisionId") + .HasColumnType("uuid"); + + b.Property("Identity") + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DivisionId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("products", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ProductProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthoredByMemberId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OrganizationId", "ProfileKey", "Version") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false); + + b.ToTable("product_profiles", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("seats", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProductId"); + + b.ToTable("teams", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("AssigneeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("AssigneeKind", "AssigneeId"); + + b.ToTable("work_items", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorMemberId") + .HasColumnType("uuid"); + + b.Property("FromStatus") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("ToStatus") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("work_item_transitions", "orgboard"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.cs new file mode 100644 index 0000000..5b1c950 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260615170931_AddProductProfiles.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.OrgBoard.Persistence.Migrations +{ + /// + public partial class AddProductProfiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "product_profiles", + schema: "orgboard", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: true), + Origin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + AuthoredByMemberId = table.Column(type: "uuid", nullable: true), + ProfileKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Version = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Summary = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + Body = table.Column(type: "text", nullable: false), + Visibility = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_product_profiles", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_product_profiles_OrganizationId", + schema: "orgboard", + table: "product_profiles", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_product_profiles_OrganizationId_ProfileKey_Version", + schema: "orgboard", + table: "product_profiles", + columns: new[] { "OrganizationId", "ProfileKey", "Version" }, + unique: true) + .Annotation("Npgsql:NullsDistinct", false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "product_profiles", + schema: "orgboard"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs index c93d23f..4242b12 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs @@ -250,6 +250,79 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations b.ToTable("products", "orgboard"); }); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ProductProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthoredByMemberId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OrganizationId", "ProfileKey", "Version") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false); + + b.ToTable("product_profiles", "orgboard"); + }); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b => { b.Property("Id") diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs index 3354e85..3e5f06e 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs @@ -14,6 +14,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti public DbSet Seats => Set(); public DbSet Agents => Set(); public DbSet AgentProfiles => Set(); + public DbSet ProductProfiles => Set(); public DbSet WorkItems => Set(); public DbSet Transitions => Set(); @@ -93,6 +94,24 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti profile.HasIndex(p => p.OrganizationId); }); + modelBuilder.Entity(profile => + { + profile.ToTable("product_profiles"); + profile.HasKey(p => p.Id); + profile.Property(p => p.ProfileKey).HasMaxLength(128).IsRequired(); + profile.Property(p => p.Name).HasMaxLength(200).IsRequired(); + profile.Property(p => p.Version).HasMaxLength(32).IsRequired(); + profile.Property(p => p.Summary).HasMaxLength(1000); + profile.Property(p => p.Visibility).HasConversion().HasMaxLength(20); + profile.Property(p => p.Status).HasConversion().HasMaxLength(20); + profile.Property(p => p.Origin).HasConversion().HasMaxLength(20); + profile.Property(p => p.ContentHash).HasMaxLength(64); + profile.HasIndex(p => new { p.OrganizationId, p.ProfileKey, p.Version }) + .IsUnique() + .AreNullsDistinct(false); + profile.HasIndex(p => p.OrganizationId); + }); + modelBuilder.Entity(workItem => { workItem.ToTable("work_items"); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileManifest.cs b/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileManifest.cs new file mode 100644 index 0000000..05caba9 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileManifest.cs @@ -0,0 +1,13 @@ +namespace TeamUp.Modules.OrgBoard.Profiles; + +/// The YAML frontmatter of a PRODUCT.md (raw, as authored). Mapped onto a ProductProfile. +internal sealed class ProductProfileManifest +{ + /// The stable key. Authored as `product:` or `id:` in the frontmatter. + public string Id { get; set; } = string.Empty; + public string Product { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = "1.0.0"; + public string? Summary { get; set; } + public string Visibility { get; set; } = "private"; +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileMarkdownParser.cs b/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileMarkdownParser.cs new file mode 100644 index 0000000..2c87fc8 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileMarkdownParser.cs @@ -0,0 +1,65 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace TeamUp.Modules.OrgBoard.Profiles; + +internal sealed record ParsedProductProfile(ProductProfileManifest Manifest, string Body); + +/// Splits a PRODUCT.md into its YAML frontmatter (between '---' fences) and Markdown body. +internal static class ProductProfileMarkdownParser +{ + private static readonly IDeserializer Yaml = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + public static ParsedProductProfile Parse(string content) + { + var text = (content ?? string.Empty).Replace("\r\n", "\n").Replace("\r", "\n").TrimStart(); + if (!text.StartsWith("---\n", StringComparison.Ordinal)) + { + throw new FormatException("PRODUCT.md must begin with a YAML frontmatter block delimited by '---'."); + } + + var rest = text[4..]; + var closeIndex = rest.IndexOf("\n---", StringComparison.Ordinal); + if (closeIndex < 0) + { + throw new FormatException("PRODUCT.md frontmatter is not closed with '---'."); + } + + var frontmatter = rest[..closeIndex]; + var afterClose = rest[(closeIndex + 1)..]; + var newline = afterClose.IndexOf('\n'); + var body = newline < 0 ? string.Empty : afterClose[(newline + 1)..].Trim(); + + var manifest = Yaml.Deserialize(frontmatter) ?? new ProductProfileManifest(); + + // The product's display name comes from `name` or `product`; the stable key from `id` or a + // slug of the product name. This lets a brief start with just `product: My Thing`. + if (string.IsNullOrWhiteSpace(manifest.Name)) + { + manifest.Name = manifest.Product; + } + + if (string.IsNullOrWhiteSpace(manifest.Id)) + { + manifest.Id = Slug(string.IsNullOrWhiteSpace(manifest.Product) ? manifest.Name : manifest.Product); + } + + if (string.IsNullOrWhiteSpace(manifest.Id)) + { + throw new FormatException("PRODUCT.md frontmatter must include a 'product' (or 'id')."); + } + + return new ParsedProductProfile(manifest, body); + } + + private static string Slug(string value) + { + var chars = value.Trim().ToLowerInvariant() + .Select(c => char.IsLetterOrDigit(c) ? c : '-') + .ToArray(); + return string.Join('-', new string(chars).Split('-', StringSplitOptions.RemoveEmptyEntries)); + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileWriter.cs b/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileWriter.cs new file mode 100644 index 0000000..e5664a1 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Profiles/ProductProfileWriter.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TeamUp.Modules.OrgBoard.Domain; +using TeamUp.Modules.OrgBoard.Persistence; + +namespace TeamUp.Modules.OrgBoard.Profiles; + +/// Upserts a product profile by (org, key, version) — the one place product profiles are written. +internal sealed class ProductProfileWriter(OrgBoardDbContext db, TimeProvider clock) +{ + /// + /// When true the row must not already exist: a colliding (org, key, version) trips the unique + /// index (DbUpdateException) instead of overwriting — the install path uses this so a race can't + /// clobber an existing profile. + /// + public async Task UpsertAsync( + ProductProfileManifest manifest, + string body, + Guid? organizationId, + ProfileOrigin origin, + Guid? authoredByMemberId, + bool insertOnly = false, + CancellationToken cancellationToken = default) + { + var now = clock.GetUtcNow(); + var canonical = $"{manifest.Id}\n{manifest.Version}\n{manifest.Name}\n{manifest.Summary}\n{body}"; + var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + + var profile = insertOnly + ? null + : await db.ProductProfiles.FirstOrDefaultAsync( + p => p.OrganizationId == organizationId && p.ProfileKey == manifest.Id && p.Version == manifest.Version, + cancellationToken); + + var isNew = profile is null; + profile ??= ProductProfile.Create(manifest.Id, manifest.Version, organizationId, now); + profile.Apply(manifest, body, contentHash, origin, authoredByMemberId, now); + + if (isNew) + { + db.ProductProfiles.Add(profile); + } + + await db.SaveChangesAsync(cancellationToken); + return profile; + } +}