Add Community Formula Library feature
Companies can now share their custom formula templates to a platform-wide community library. Other tenants can browse, preview, and import formulas as independent local copies. Includes attribution (source company name), "Inspired by" lineage for re-contributed formulas, import counts, own-formula badge, cascade diagram nullification, and AI assistant + help docs updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -289,6 +289,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// </summary>
|
||||
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
||||
|
||||
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
|
||||
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
|
||||
|
||||
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
|
||||
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
|
||||
|
||||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BugReport> BugReports { get; set; }
|
||||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||||
@@ -2074,6 +2080,49 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
||||
|
||||
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
|
||||
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasOne(f => f.InspiredBy)
|
||||
.WithMany()
|
||||
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasIndex(f => f.SourceCompanyId)
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasIndex(f => f.IsPublished)
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||
|
||||
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasOne(i => i.FormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FormulaLibraryItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasOne(i => i.ResultingCustomItemTemplate)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||
|
||||
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
|
||||
modelBuilder.Entity<CustomItemTemplate>()
|
||||
.HasOne(t => t.SourceFormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+11119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFormulaLibrary : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsModifiedFromSource",
|
||||
table: "CustomItemTemplates",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SourceFormulaLibraryItemId",
|
||||
table: "CustomItemTemplates",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FormulaLibraryItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Tags = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IndustryHint = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SourceCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
SourceCompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
SourceCompanyName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
InspiredByFormulaLibraryItemId = table.Column<int>(type: "int", nullable: true),
|
||||
SharedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SharedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
IsPublished = table.Column<bool>(type: "bit", nullable: false),
|
||||
ImportCount = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FormulaLibraryItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FormulaLibraryItems_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
|
||||
column: x => x.InspiredByFormulaLibraryItemId,
|
||||
principalTable: "FormulaLibraryItems",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FormulaLibraryImports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
|
||||
ImportedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ImportedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ResultingCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FormulaLibraryImports", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FormulaLibraryImports_CustomItemTemplates_ResultingCustomItemTemplateId",
|
||||
column: x => x.ResultingCustomItemTemplateId,
|
||||
principalTable: "CustomItemTemplates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FormulaLibraryImports_FormulaLibraryItems_FormulaLibraryItemId",
|
||||
column: x => x.FormulaLibraryItemId,
|
||||
principalTable: "FormulaLibraryItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
|
||||
table: "CustomItemTemplates",
|
||||
column: "SourceFormulaLibraryItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FormulaLibraryImports_Company_Item",
|
||||
table: "FormulaLibraryImports",
|
||||
columns: new[] { "CompanyId", "FormulaLibraryItemId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FormulaLibraryImports_FormulaLibraryItemId",
|
||||
table: "FormulaLibraryImports",
|
||||
column: "FormulaLibraryItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FormulaLibraryImports_ResultingCustomItemTemplateId",
|
||||
table: "FormulaLibraryImports",
|
||||
column: "ResultingCustomItemTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
|
||||
table: "FormulaLibraryItems",
|
||||
column: "InspiredByFormulaLibraryItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FormulaLibraryItems_IsPublished",
|
||||
table: "FormulaLibraryItems",
|
||||
column: "IsPublished");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FormulaLibraryItems_SourceCompanyId",
|
||||
table: "FormulaLibraryItems",
|
||||
column: "SourceCompanyId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
|
||||
table: "CustomItemTemplates",
|
||||
column: "SourceFormulaLibraryItemId",
|
||||
principalTable: "FormulaLibraryItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
|
||||
table: "CustomItemTemplates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FormulaLibraryImports");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FormulaLibraryItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
|
||||
table: "CustomItemTemplates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsModifiedFromSource",
|
||||
table: "CustomItemTemplates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceFormulaLibraryItemId",
|
||||
table: "CustomItemTemplates");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2711,6 +2711,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsModifiedFromSource")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -2725,6 +2728,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("RateLabel")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("SourceFormulaLibraryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -2733,6 +2739,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceFormulaLibraryItemId");
|
||||
|
||||
b.ToTable("CustomItemTemplates");
|
||||
});
|
||||
|
||||
@@ -3437,6 +3445,154 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("FixedAssetDepreciationEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("FormulaLibraryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("ImportedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ImportedByUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("ResultingCustomItemTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FormulaLibraryItemId");
|
||||
|
||||
b.HasIndex("ResultingCustomItemTemplateId");
|
||||
|
||||
b.HasIndex("CompanyId", "FormulaLibraryItemId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||
|
||||
b.ToTable("FormulaLibraryImports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("DefaultRate")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DiagramImagePath")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("FieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Formula")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ImportCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("IndustryHint")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("InspiredByFormulaLibraryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("OutputMode")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RateLabel")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("SharedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("SharedByUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("SourceCompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SourceCompanyName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("SourceCustomItemTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InspiredByFormulaLibraryItemId");
|
||||
|
||||
b.HasIndex("IsPublished")
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||
|
||||
b.HasIndex("SourceCompanyId")
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||
|
||||
b.ToTable("FormulaLibraryItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -6868,7 +7024,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956),
|
||||
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6879,7 +7035,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962),
|
||||
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6890,7 +7046,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964),
|
||||
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -9263,6 +9419,16 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Invoice");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "SourceFormulaLibraryItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceFormulaLibraryItemId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SourceFormulaLibraryItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||
@@ -9419,6 +9585,35 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("JournalEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("FormulaLibraryItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "ResultingCustomItemTemplate")
|
||||
.WithMany()
|
||||
.HasForeignKey("ResultingCustomItemTemplateId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("FormulaLibraryItem");
|
||||
|
||||
b.Navigation("ResultingCustomItemTemplate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "InspiredBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("InspiredByFormulaLibraryItemId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.Navigation("InspiredBy");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||
|
||||
@@ -130,6 +130,10 @@ public class UnitOfWork : IUnitOfWork
|
||||
// Custom Formula Templates
|
||||
private IRepository<CustomItemTemplate>? _customItemTemplates;
|
||||
|
||||
// Formula Community Library
|
||||
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
|
||||
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
|
||||
|
||||
// Purchase Orders
|
||||
private IPurchaseOrderRepository? _purchaseOrders;
|
||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||
@@ -476,6 +480,14 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<CustomItemTemplate> CustomItemTemplates =>
|
||||
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="FormulaLibraryItem"/> community library entries; platform-level, no tenant filter.</summary>
|
||||
public IPlainRepository<FormulaLibraryItem> FormulaLibrary =>
|
||||
_formulaLibrary ??= new PlainRepository<FormulaLibraryItem>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="FormulaLibraryImport"/> per-company import records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<FormulaLibraryImport> FormulaLibraryImports =>
|
||||
_formulaLibraryImports ??= new Repository<FormulaLibraryImport>(_context);
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||
public IJobTemplateRepository JobTemplates =>
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the community formula library: sharing a company template to the platform-wide
|
||||
/// library, removing it, browsing published entries, and importing an entry as a local copy.
|
||||
/// </summary>
|
||||
public class FormulaLibraryService : IFormulaLibraryService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<FormulaLibraryService> _logger;
|
||||
|
||||
public FormulaLibraryService(IUnitOfWork unitOfWork, IMapper mapper, ILogger<FormulaLibraryService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
|
||||
int companyId, string? search, string? outputMode, string? industryHint)
|
||||
{
|
||||
var items = await _unitOfWork.FormulaLibrary.FindAsync(i => i.IsPublished);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var lower = search.ToLowerInvariant();
|
||||
items = items.Where(i =>
|
||||
i.Name.ToLowerInvariant().Contains(lower) ||
|
||||
(i.Description != null && i.Description.ToLowerInvariant().Contains(lower)) ||
|
||||
(i.Tags != null && i.Tags.ToLowerInvariant().Contains(lower)) ||
|
||||
i.SourceCompanyName.ToLowerInvariant().Contains(lower));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputMode))
|
||||
items = items.Where(i => i.OutputMode == outputMode);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(industryHint))
|
||||
items = items.Where(i => i.IndustryHint != null &&
|
||||
i.IndustryHint.ToLowerInvariant().Contains(industryHint.ToLowerInvariant()));
|
||||
|
||||
// Load InspiredBy for attribution line
|
||||
var itemList = items.ToList();
|
||||
var inspiredByIds = itemList
|
||||
.Where(i => i.InspiredByFormulaLibraryItemId.HasValue)
|
||||
.Select(i => i.InspiredByFormulaLibraryItemId!.Value)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
Dictionary<int, FormulaLibraryItem> inspirations = new();
|
||||
foreach (var id in inspiredByIds)
|
||||
{
|
||||
var parent = await _unitOfWork.FormulaLibrary.GetByIdAsync(id);
|
||||
if (parent != null) inspirations[id] = parent;
|
||||
}
|
||||
|
||||
// Attach navigation properties manually (PlainRepository doesn't eager-load)
|
||||
foreach (var item in itemList)
|
||||
{
|
||||
if (item.InspiredByFormulaLibraryItemId.HasValue &&
|
||||
inspirations.TryGetValue(item.InspiredByFormulaLibraryItemId.Value, out var parent))
|
||||
item.InspiredBy = parent;
|
||||
}
|
||||
|
||||
// Determine which entries this company has already imported
|
||||
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
imp => imp.CompanyId == companyId && !imp.IsDeleted);
|
||||
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
|
||||
|
||||
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
|
||||
for (int i = 0; i < dtos.Count; i++)
|
||||
{
|
||||
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
|
||||
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
|
||||
}
|
||||
|
||||
return dtos.OrderByDescending(d => d.ImportCount).ThenBy(d => d.Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId)
|
||||
{
|
||||
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (item == null || !item.IsPublished) return null;
|
||||
|
||||
if (item.InspiredByFormulaLibraryItemId.HasValue)
|
||||
item.InspiredBy = await _unitOfWork.FormulaLibrary.GetByIdAsync(
|
||||
item.InspiredByFormulaLibraryItemId.Value);
|
||||
|
||||
var dto = _mapper.Map<FormulaLibraryDetailDto>(item);
|
||||
|
||||
var imp = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
|
||||
dto.AlreadyImported = imp.Any();
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request)
|
||||
{
|
||||
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(request.CustomItemTemplateId);
|
||||
if (template == null || template.CompanyId != companyId)
|
||||
throw new InvalidOperationException("Template not found.");
|
||||
|
||||
if (!CanShare(template))
|
||||
throw new InvalidOperationException("This template is not eligible to be shared.");
|
||||
|
||||
// Determine "Inspired by" — if this was imported from the library
|
||||
int? inspiredById = null;
|
||||
if (template.SourceFormulaLibraryItemId.HasValue && template.IsModifiedFromSource)
|
||||
inspiredById = template.SourceFormulaLibraryItemId;
|
||||
|
||||
// Get company name for attribution
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
var companyName = company?.CompanyName ?? "Unknown Company";
|
||||
|
||||
// Re-use existing row if one exists (re-share after unshare, or update after edits)
|
||||
var existing = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
||||
f => f.SourceCustomItemTemplateId == template.Id && f.SourceCompanyId == companyId);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
CopyFromTemplate(existing, template, companyName, request);
|
||||
existing.InspiredByFormulaLibraryItemId = inspiredById;
|
||||
existing.IsPublished = true;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(existing);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return existing.Id;
|
||||
}
|
||||
|
||||
var libraryItem = new FormulaLibraryItem
|
||||
{
|
||||
SharedByUserId = userId,
|
||||
SharedAt = DateTime.UtcNow,
|
||||
SourceCustomItemTemplateId = template.Id,
|
||||
SourceCompanyId = companyId,
|
||||
SourceCompanyName = companyName,
|
||||
InspiredByFormulaLibraryItemId = inspiredById,
|
||||
IsPublished = true,
|
||||
};
|
||||
CopyFromTemplate(libraryItem, template, companyName, request);
|
||||
|
||||
await _unitOfWork.FormulaLibrary.AddAsync(libraryItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return libraryItem.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnshareAsync(int libraryItemId, int companyId)
|
||||
{
|
||||
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (item == null || item.SourceCompanyId != companyId) return;
|
||||
|
||||
item.IsPublished = false;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(item);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ImportAsync(int libraryItemId, int companyId, string userId)
|
||||
{
|
||||
var libraryItem = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (libraryItem == null || !libraryItem.IsPublished)
|
||||
throw new InvalidOperationException("Library entry not found or no longer published.");
|
||||
|
||||
// Return existing import if already imported
|
||||
var existingImports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
|
||||
var existingImport = existingImports.FirstOrDefault();
|
||||
if (existingImport != null) return existingImport.ResultingCustomItemTemplateId;
|
||||
|
||||
// Create a local copy as a new CustomItemTemplate
|
||||
var template = new CustomItemTemplate
|
||||
{
|
||||
CompanyId = companyId,
|
||||
Name = libraryItem.Name,
|
||||
Description = libraryItem.Description,
|
||||
OutputMode = libraryItem.OutputMode,
|
||||
FieldsJson = libraryItem.FieldsJson,
|
||||
Formula = libraryItem.Formula,
|
||||
DefaultRate = libraryItem.DefaultRate,
|
||||
RateLabel = libraryItem.RateLabel,
|
||||
Notes = libraryItem.Notes,
|
||||
DiagramImagePath = libraryItem.DiagramImagePath,
|
||||
DisplayOrder = 0,
|
||||
IsActive = true,
|
||||
SourceFormulaLibraryItemId = libraryItemId,
|
||||
IsModifiedFromSource = false,
|
||||
};
|
||||
|
||||
await _unitOfWork.CustomItemTemplates.AddAsync(template);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var importRecord = new FormulaLibraryImport
|
||||
{
|
||||
CompanyId = companyId,
|
||||
FormulaLibraryItemId = libraryItemId,
|
||||
ImportedByUserId = userId,
|
||||
ImportedAt = DateTime.UtcNow,
|
||||
ResultingCustomItemTemplateId = template.Id,
|
||||
};
|
||||
await _unitOfWork.FormulaLibraryImports.AddAsync(importRecord);
|
||||
|
||||
// Increment import counter
|
||||
libraryItem.ImportCount++;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return template.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId)
|
||||
{
|
||||
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||
if (template == null || template.CompanyId != companyId)
|
||||
return new FormulaLibraryStatusDto { CanShare = false };
|
||||
|
||||
var dto = new FormulaLibraryStatusDto { CanShare = CanShare(template) };
|
||||
|
||||
// Populate import attribution
|
||||
if (template.SourceFormulaLibraryItemId.HasValue)
|
||||
{
|
||||
var source = await _unitOfWork.FormulaLibrary.GetByIdAsync(template.SourceFormulaLibraryItemId.Value);
|
||||
if (source != null)
|
||||
{
|
||||
dto.ImportedFromName = source.Name;
|
||||
dto.ImportedFromCompany = source.SourceCompanyName;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this template has an active library entry
|
||||
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
||||
f => f.SourceCustomItemTemplateId == templateId && f.SourceCompanyId == companyId);
|
||||
if (libraryItem != null)
|
||||
{
|
||||
dto.LibraryItemId = libraryItem.Id;
|
||||
dto.IsPublished = libraryItem.IsPublished;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId)
|
||||
{
|
||||
// Null out on the library item published from this template
|
||||
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
||||
f => f.SourceCustomItemTemplateId == sourceCustomItemTemplateId);
|
||||
if (libraryItem != null && libraryItem.DiagramImagePath != null)
|
||||
{
|
||||
libraryItem.DiagramImagePath = null;
|
||||
libraryItem.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
|
||||
|
||||
// Null out on all imported copies
|
||||
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
i => i.FormulaLibraryItemId == libraryItem.Id && !i.IsDeleted);
|
||||
foreach (var imp in imports)
|
||||
{
|
||||
var copy = await _unitOfWork.CustomItemTemplates.GetByIdAsync(
|
||||
imp.ResultingCustomItemTemplateId);
|
||||
if (copy != null && copy.DiagramImagePath != null)
|
||||
{
|
||||
copy.DiagramImagePath = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A template is shareable if it was created fresh (no source library item) or
|
||||
/// if it was imported but then modified by the company.
|
||||
/// </summary>
|
||||
private static bool CanShare(CustomItemTemplate t) =>
|
||||
t.SourceFormulaLibraryItemId == null || t.IsModifiedFromSource;
|
||||
|
||||
private static void CopyFromTemplate(
|
||||
FormulaLibraryItem dest, CustomItemTemplate src, string companyName, ShareFormulaRequest req)
|
||||
{
|
||||
dest.Name = src.Name;
|
||||
dest.Description = src.Description;
|
||||
dest.OutputMode = src.OutputMode;
|
||||
dest.FieldsJson = src.FieldsJson;
|
||||
dest.Formula = src.Formula;
|
||||
dest.DefaultRate = src.DefaultRate;
|
||||
dest.RateLabel = src.RateLabel;
|
||||
dest.Notes = src.Notes;
|
||||
dest.DiagramImagePath = src.DiagramImagePath;
|
||||
dest.SourceCompanyName = companyName;
|
||||
dest.Tags = req.Tags?.Trim();
|
||||
dest.IndustryHint = req.IndustryHint?.Trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user