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:
@@ -35,6 +35,7 @@ public class CompanySettingsController : Controller
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly IAzureBlobStorageService _blobStorage;
|
||||
private readonly ICustomFormulaAiService _formulaAiService;
|
||||
private readonly IFormulaLibraryService _formulaLibraryService;
|
||||
|
||||
public CompanySettingsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -49,7 +50,8 @@ public class CompanySettingsController : Controller
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
IAzureBlobStorageService blobStorage,
|
||||
ICustomFormulaAiService formulaAiService)
|
||||
ICustomFormulaAiService formulaAiService,
|
||||
IFormulaLibraryService formulaLibraryService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -64,6 +66,7 @@ public class CompanySettingsController : Controller
|
||||
_signInManager = signInManager;
|
||||
_blobStorage = blobStorage;
|
||||
_formulaAiService = formulaAiService;
|
||||
_formulaLibraryService = formulaLibraryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3080,6 +3083,11 @@ public class CompanySettingsController : Controller
|
||||
|
||||
_mapper.Map(dto, entity);
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// If this was imported from the library, mark it as modified so the share button appears
|
||||
if (entity.SourceFormulaLibraryItemId.HasValue)
|
||||
entity.IsModifiedFromSource = true;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
@@ -3100,6 +3108,52 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// ── Community Library: share / unshare / status ───────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the community library status for a given template: whether it is published,
|
||||
/// eligible to share, and where it was originally imported from if applicable.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> FormulaLibraryStatus(int templateId)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { canShare = false });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var status = await _formulaLibraryService.GetTemplateLibraryStatusAsync(templateId, companyId);
|
||||
return Json(status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a company template to the community library (or re-publishes after unshare).
|
||||
/// Only templates that are original creations or modified imports may be shared.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> ShareFormula([FromBody] PowderCoating.Application.DTOs.Company.ShareFormulaRequest request)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
var libraryItemId = await _formulaLibraryService.ShareAsync(companyId, userId, request);
|
||||
return Json(new { success = true, libraryItemId });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Json(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes a template from the community library. Existing company imports are unaffected.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UnshareFormula(int libraryItemId)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
await _formulaLibraryService.UnshareAsync(libraryItemId, companyId);
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a diagram image for a template to blob storage container
|
||||
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Community formula library — browse published formulas from all companies and import
|
||||
/// them into the current company's local template list.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanViewData)]
|
||||
public class FormulaLibraryController : Controller
|
||||
{
|
||||
private readonly IFormulaLibraryService _libraryService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly IAzureBlobStorageService _blobStorage;
|
||||
|
||||
public FormulaLibraryController(
|
||||
IFormulaLibraryService libraryService,
|
||||
ITenantContext tenantContext,
|
||||
IMapper mapper,
|
||||
IAzureBlobStorageService blobStorage)
|
||||
{
|
||||
_libraryService = libraryService;
|
||||
_tenantContext = tenantContext;
|
||||
_mapper = mapper;
|
||||
_blobStorage = blobStorage;
|
||||
}
|
||||
|
||||
/// <summary>Browse the community library with optional search and filter params.</summary>
|
||||
// GET: /FormulaLibrary
|
||||
public async Task<IActionResult> Index(
|
||||
string? search = null,
|
||||
string? outputMode = null,
|
||||
string? industryHint = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return RedirectToAction("Index", "Home");
|
||||
|
||||
var items = await _libraryService.BrowseAsync(companyId.Value, search, outputMode, industryHint);
|
||||
|
||||
ViewBag.Search = search;
|
||||
ViewBag.OutputMode = outputMode;
|
||||
ViewBag.IndustryHint = industryHint;
|
||||
ViewBag.TotalCount = items.Count();
|
||||
|
||||
return View(items);
|
||||
}
|
||||
|
||||
/// <summary>Returns full detail JSON for the import preview modal.</summary>
|
||||
// GET: /FormulaLibrary/Detail/5
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Detail(int id)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return Json(new { error = "No company context." });
|
||||
|
||||
var detail = await _libraryService.GetDetailAsync(id, companyId.Value);
|
||||
if (detail == null) return NotFound();
|
||||
|
||||
return Json(detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves a formula diagram image by blob storage path. Used for library cards where the
|
||||
/// diagram belongs to another company's template blob container.
|
||||
/// </summary>
|
||||
// GET: /FormulaLibrary/Diagram?path=...
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Diagram(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return NotFound();
|
||||
|
||||
// Sanitize: path must not escape the blob container
|
||||
if (path.Contains("..") || path.StartsWith("/") || path.StartsWith("\\"))
|
||||
return BadRequest();
|
||||
|
||||
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", path);
|
||||
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
|
||||
return File(bytes, contentType ?? "image/jpeg");
|
||||
}
|
||||
|
||||
/// <summary>Imports a library entry as a new local template for the current company.</summary>
|
||||
// POST: /FormulaLibrary/Import
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> Import(int libraryItemId)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
return Json(new { success = false, message = "No company context." });
|
||||
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
var templateId = await _libraryService.ImportAsync(libraryItemId, companyId.Value, userId);
|
||||
return Json(new { success = true, templateId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user