Versioned PRODUCT.md library + marketplace — backend (Slice 4)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
using TeamUp.Modules.OrgBoard.Profiles;
|
||||||
|
using TeamUp.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ProductProfile : Entity
|
||||||
|
{
|
||||||
|
/// <summary>Owning org. Null = a free shared builtin.</summary>
|
||||||
|
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 };
|
||||||
|
|
||||||
|
/// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists/unlists this version on the marketplace. Listing requires a Published profile.</summary>
|
||||||
|
public void SetVisibility(ProfileVisibility visibility, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
Visibility = visibility == ProfileVisibility.Public && Status != ProfileStatus.Published
|
||||||
|
? ProfileVisibility.PrivateToOrg
|
||||||
|
: visibility;
|
||||||
|
UpdatedAtUtc = nowUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconstruct the PRODUCT.md (frontmatter + body) to store as a product's identity.</summary>
|
||||||
|
public string ToMarkdown()
|
||||||
|
{
|
||||||
|
var lines = new List<string> { $"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;
|
||||||
|
}
|
||||||
@@ -100,3 +100,28 @@ internal sealed record AgentProfileSummary(
|
|||||||
internal sealed record AgentProfileDetail(AgentProfileSummary Profile, string Body);
|
internal sealed record AgentProfileDetail(AgentProfileSummary Profile, string Body);
|
||||||
|
|
||||||
internal sealed record MarketplaceProfileEntry(AgentProfileSummary Profile, bool AlreadyInLibrary);
|
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);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ internal static class OrgBoardEndpoints
|
|||||||
group.MapGet("/performance", PerformanceEndpoints.Get).RequireAuthorization();
|
group.MapGet("/performance", PerformanceEndpoints.Get).RequireAuthorization();
|
||||||
|
|
||||||
AgentProfileEndpoints.MapTo(group);
|
AgentProfileEndpoints.MapTo(group);
|
||||||
|
ProductProfileEndpoints.MapTo(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<IResult> 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<IResult> List(
|
||||||
|
Guid? organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IQueryable<ProductProfile> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ public sealed class OrgBoardModule : IModule
|
|||||||
services.AddScoped<IBoardStats, BoardStats>();
|
services.AddScoped<IBoardStats, BoardStats>();
|
||||||
services.AddScoped<QaHandoffTrigger>();
|
services.AddScoped<QaHandoffTrigger>();
|
||||||
services.AddScoped<AgentProfileWriter>();
|
services.AddScoped<AgentProfileWriter>();
|
||||||
|
services.AddScoped<ProductProfileWriter>();
|
||||||
services.AddScoped<IStartupSeeder, AgentProfileSeeder>();
|
services.AddScoped<IStartupSeeder, AgentProfileSeeder>();
|
||||||
services.TryAddSingleton(TimeProvider.System);
|
services.TryAddSingleton(TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|||||||
+488
@@ -0,0 +1,488 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeamUp.Modules.OrgBoard.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OrgBoardDbContext))]
|
||||||
|
[Migration("20260615170931_AddProductProfiles")]
|
||||||
|
partial class AddProductProfiles
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("orgboard")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApiConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Autonomy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Docs")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FallbackApiConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<Guid>>("McpServerIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("uuid[]");
|
||||||
|
|
||||||
|
b.Property<string>("Monogram")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("Persona")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("SeatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeatId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("agents", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.AgentProfile", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AuthoredByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ContentHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Monogram")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Origin")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ProfileKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("RecommendedAutonomy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Roles")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.ToTable("divisions", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("organizations", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Product", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DivisionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Identity")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AuthoredByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ContentHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Origin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("ProfileKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AgentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RoleName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.ToTable("seats", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssigneeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssigneeKind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.HasIndex("AssigneeKind", "AssigneeId");
|
||||||
|
|
||||||
|
b.ToTable("work_items", "orgboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActorMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FromStatus")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeamId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ToStatus")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<Guid>("WorkItemId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId");
|
||||||
|
|
||||||
|
b.HasIndex("WorkItemId");
|
||||||
|
|
||||||
|
b.ToTable("work_item_transitions", "orgboard");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddProductProfiles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "product_profiles",
|
||||||
|
schema: "orgboard",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
Origin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
AuthoredByMemberId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ProfileKey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Version = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
Summary = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Body = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Visibility = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTimeOffset>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "product_profiles",
|
||||||
|
schema: "orgboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -250,6 +250,79 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
|||||||
b.ToTable("products", "orgboard");
|
b.ToTable("products", "orgboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ProductProfile", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AuthoredByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ContentHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Origin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("ProfileKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("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 =>
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
public DbSet<Seat> Seats => Set<Seat>();
|
public DbSet<Seat> Seats => Set<Seat>();
|
||||||
public DbSet<Agent> Agents => Set<Agent>();
|
public DbSet<Agent> Agents => Set<Agent>();
|
||||||
public DbSet<AgentProfile> AgentProfiles => Set<AgentProfile>();
|
public DbSet<AgentProfile> AgentProfiles => Set<AgentProfile>();
|
||||||
|
public DbSet<ProductProfile> ProductProfiles => Set<ProductProfile>();
|
||||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||||
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
|
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
|
||||||
|
|
||||||
@@ -93,6 +94,24 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
profile.HasIndex(p => p.OrganizationId);
|
profile.HasIndex(p => p.OrganizationId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ProductProfile>(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<string>().HasMaxLength(20);
|
||||||
|
profile.Property(p => p.Status).HasConversion<string>().HasMaxLength(20);
|
||||||
|
profile.Property(p => p.Origin).HasConversion<string>().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 =>
|
modelBuilder.Entity<WorkItem>(workItem =>
|
||||||
{
|
{
|
||||||
workItem.ToTable("work_items");
|
workItem.ToTable("work_items");
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TeamUp.Modules.OrgBoard.Profiles;
|
||||||
|
|
||||||
|
/// <summary>The YAML frontmatter of a PRODUCT.md (raw, as authored). Mapped onto a ProductProfile.</summary>
|
||||||
|
internal sealed class ProductProfileManifest
|
||||||
|
{
|
||||||
|
/// <summary>The stable key. Authored as `product:` or `id:` in the frontmatter.</summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using YamlDotNet.Serialization;
|
||||||
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
|
namespace TeamUp.Modules.OrgBoard.Profiles;
|
||||||
|
|
||||||
|
internal sealed record ParsedProductProfile(ProductProfileManifest Manifest, string Body);
|
||||||
|
|
||||||
|
/// <summary>Splits a PRODUCT.md into its YAML frontmatter (between '---' fences) and Markdown body.</summary>
|
||||||
|
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<ProductProfileManifest>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Upserts a product profile by (org, key, version) — the one place product profiles are written.</summary>
|
||||||
|
internal sealed class ProductProfileWriter(OrgBoardDbContext db, TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <param name="insertOnly">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
public async Task<ProductProfile> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user