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:
2026-05-27 21:54:51 -04:00
parent 32d09b38f1
commit ca7e905832
24 changed files with 12959 additions and 10 deletions
@@ -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();
}
}