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;
+ }
+}