Add Columbia Coatings API client, DTOs, and sync settings

Phase 1b of the Columbia Coatings integration: the typed read client and
its configuration, ahead of the sync/mapper service.

- ColumbiaProductDtos: wire-shape models for GET /products. tiered_pricing
  is captured as JsonElement because the API returns it as an object on
  simple products but an empty array on variable ones — binding it raw
  avoids a deserialization throw; the mapper interprets it.
- IColumbiaCoatingsApiClient / ColumbiaCoatingsApiClient: pages the catalog
  via GET /products (NOT the export download_url, which is Cloudflare-blocked
  for server clients). Sends X-API-Key from config, honors 429/Retry-After,
  and THROWS on any page failure so a partial pull can never be mistaken for
  the full catalog (protects the later discontinuation sweep).
- ColumbiaIntegrationConstants: single home for config keys, setting keys,
  and the derived Source/manufacturer/category values.
- Config: Columbia:ApiKey (blank — secret supplied per environment) and
  Columbia:BaseUrl in appsettings.
- SeedColumbiaSyncSettings migration: seeds SuperAdmin-managed platform
  settings ColumbiaSyncEnabled (off by default), ColumbiaSyncIntervalDays
  (7), and last-sync tracking, under a new "Integrations" group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 10:47:00 -04:00
parent c98f9faf63
commit 39f61b9718
9 changed files with 11831 additions and 3 deletions
@@ -0,0 +1,40 @@
namespace PowderCoating.Application.Constants;
/// <summary>
/// Central constants for the Columbia Coatings catalog integration — config keys, platform-setting
/// keys, and the derived provenance/manufacturer/category values the mapper assigns. Kept in one
/// place so the API client, sync mapper, scheduled job, and purge logic all agree on the strings.
/// </summary>
public static class ColumbiaIntegrationConstants
{
// ── Configuration keys (appsettings.json / environment) ───────────────
/// <summary>API key — secret, lives in config not the platform-settings DB.</summary>
public const string ConfigApiKey = "Columbia:ApiKey";
public const string ConfigBaseUrl = "Columbia:BaseUrl";
public const string DefaultBaseUrl = "https://columbiacoatings.com";
public const string ProductsPath = "/wp-json/cca/v1/products";
/// <summary>API caps per_page at 100.</summary>
public const int MaxPerPage = 100;
// ── Platform setting keys (SuperAdmin-managed, non-secret) ────────────
public const string SettingEnabled = "ColumbiaSyncEnabled";
public const string SettingIntervalDays = "ColumbiaSyncIntervalDays";
public const string SettingLastSyncedAt = "ColumbiaLastSyncedAt";
public const string SettingLastResult = "ColumbiaLastSyncResult";
public const int DefaultSyncIntervalDays = 7;
// ── Provenance ────────────────────────────────────────────────────────
/// <summary>Stored in <c>PowderCatalogItem.Source</c> — the purge key for right-to-delete.</summary>
public const string SourceName = "Columbia Coatings API";
// ── Derived manufacturers (PowderCatalogItem.VendorName) ──────────────
public const string ManufacturerColumbia = "Columbia Coatings";
public const string ManufacturerPpg = "PPG";
public const string ManufacturerKp = "KP Pigments";
// ── Derived category (PowderCatalogItem.Category) ─────────────────────
public const string CategoryPowderAdditives = "Powder Additives";
}
@@ -0,0 +1,142 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowderCoating.Application.DTOs.Columbia;
/// <summary>
/// One page of the Columbia Coatings <c>GET /products</c> response: a list of products plus
/// pagination metadata. Property names are snake_case in the API and bound via the snake-case
/// naming policy configured on the client's <see cref="JsonSerializerOptions"/>.
/// </summary>
public class ColumbiaProductsResponse
{
public List<ColumbiaProduct> Items { get; set; } = new();
public ColumbiaPagination? Pagination { get; set; }
}
/// <summary>Pagination block returned alongside a product page.</summary>
public class ColumbiaPagination
{
public int Page { get; set; }
public int PerPage { get; set; }
public int Total { get; set; }
public int TotalPages { get; set; }
}
/// <summary>
/// A single Columbia Coatings product as returned by the API. This mirrors the wire shape, not our
/// catalog model — mapping into <c>PowderCatalogItem</c> happens in the sync mapper. Prices arrive
/// as strings; cure/spec fields are free text; documents are direct URLs.
/// </summary>
public class ColumbiaProduct
{
public int Id { get; set; }
/// <summary>"simple" or "variable". Variable products carry packaging/size variants in
/// <see cref="VariationPricing"/> and leave <see cref="TieredPricing"/> as an empty array.</summary>
public string ProductType { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Sku { get; set; } = string.Empty;
public string Permalink { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
/// <summary>Columbia-specific values seen include "In Stock"/"instock", "formulated",
/// "drop_shipped", "multiple_variations", "outofstock", "onbackorder" — mixed casing.</summary>
public string StockStatus { get; set; } = string.Empty;
// ── Pricing (all strings on the wire) ─────────────────────────────────
public string Price { get; set; } = string.Empty;
public string RegularPrice { get; set; } = string.Empty;
public string SalePrice { get; set; } = string.Empty;
/// <summary>
/// Quantity-break pricing. POLYMORPHIC on the wire: an object
/// (<c>{type, minimum_quantity, tiers:[...]}</c>) on simple products, but an empty ARRAY
/// (<c>[]</c>) on variable products. Captured as a raw <see cref="JsonElement"/> so
/// deserialization never throws on the type mismatch; the mapper interprets it.
/// </summary>
public JsonElement TieredPricing { get; set; }
/// <summary>Per-variant pricing for variable products (e.g. Bulk vs 1 lb Bags, or gram sizes).
/// Each variant has its own SKU and price. Empty for simple products.</summary>
public List<ColumbiaVariationPricing>? VariationPricing { get; set; }
// ── Documents (direct URLs) ───────────────────────────────────────────
public string SafetyDataSheet { get; set; } = string.Empty;
public string TechnicalDataSheet { get; set; } = string.Empty;
public string ProductFlyer { get; set; } = string.Empty;
public string ProductBrochure { get; set; } = string.Empty;
// ── Coating spec free-text ────────────────────────────────────────────
public string CureSchedule { get; set; } = string.Empty;
public string MilThickness { get; set; } = string.Empty;
/// <summary>Resin chemistry (e.g. "Polyester/TGIC", "TGIC", "Epoxy"). NOT finish/gloss.</summary>
public string Type { get; set; } = string.Empty;
public string ReleaseDate { get; set; } = string.Empty;
public string FormulationDate { get; set; } = string.Empty;
/// <summary>Free-text reformulation log, e.g. "Formulation Change: 05/22/26".</summary>
public string FormulationDateChanges { get; set; } = string.Empty;
// ── Content ───────────────────────────────────────────────────────────
/// <summary>HTML product description (WordPress markup).</summary>
public string Description { get; set; } = string.Empty;
public string ShortDescription { get; set; } = string.Empty;
public ColumbiaImage? FeaturedImage { get; set; }
public List<ColumbiaImage> GalleryImages { get; set; } = new();
// ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
public List<ColumbiaNamed> Categories { get; set; } = new();
public List<ColumbiaNamed> Tags { get; set; } = new();
public List<ColumbiaNamed> PaColorGroup { get; set; } = new();
public List<ColumbiaAttribute> Attributes { get; set; } = new();
}
/// <summary>A pricing variant of a variable product (own SKU, own price/tiers).</summary>
public class ColumbiaVariationPricing
{
public int Id { get; set; }
public string Sku { get; set; } = string.Empty;
public List<ColumbiaAttributeValue> Attributes { get; set; } = new();
public string StockStatus { get; set; } = string.Empty;
public string Price { get; set; } = string.Empty;
public string RegularPrice { get; set; } = string.Empty;
public string SalePrice { get; set; } = string.Empty;
/// <summary>Same polymorphic object-or-array shape as the parent; captured raw.</summary>
public JsonElement TieredPricing { get; set; }
}
/// <summary>An image object — featured or gallery.</summary>
public class ColumbiaImage
{
public int Id { get; set; }
public string Src { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Alt { get; set; } = string.Empty;
}
/// <summary>A simple <c>{name}</c> taxonomy entry (category, tag, or color group).</summary>
public class ColumbiaNamed
{
public string Name { get; set; } = string.Empty;
}
/// <summary>A product attribute with its option list, e.g. Color Group → [Blue], Packaging → [Bulk, 1 lb Bags].</summary>
public class ColumbiaAttribute
{
public string Name { get; set; } = string.Empty;
public List<ColumbiaNamed> Options { get; set; } = new();
}
/// <summary>A resolved attribute value on a specific variation, e.g. Packaging → "Bulk".</summary>
public class ColumbiaAttributeValue
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,35 @@
using PowderCoating.Application.DTOs.Columbia;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Typed client for the Columbia Coatings product catalog API (<c>/wp-json/cca/v1</c>).
/// Read-only: lists products via the paged <c>GET /products</c> endpoint.
/// <para>
/// We deliberately page <c>/products</c> rather than using the bulk <c>export.json</c> download:
/// the export returns a temporary <c>download_url</c> to a static file under <c>/wp-content/uploads</c>,
/// which sits behind Cloudflare bot protection and 403s for non-browser clients. The
/// <c>/wp-json</c> API routes are allowlisted via the API key, so paging is the only path that
/// works reliably from a server.
/// </para>
/// </summary>
public interface IColumbiaCoatingsApiClient
{
/// <summary>
/// True when an API key is configured (<c>Columbia:ApiKey</c>). When false, callers should
/// skip the sync entirely rather than issue unauthenticated requests.
/// </summary>
bool IsConfigured { get; }
/// <summary>
/// Retrieves a single page of products. <paramref name="perPage"/> is capped at 100 by the API.
/// </summary>
Task<ColumbiaProductsResponse> GetProductsPageAsync(int page, int perPage, CancellationToken cancellationToken = default);
/// <summary>
/// Pages through the entire catalog and returns every product. Honors rate limiting
/// (429 / Retry-After). THROWS if any page fails after retries — callers must treat an
/// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
/// </summary>
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeedColumbiaSyncSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Seed the SuperAdmin-managed platform settings for the Columbia Coatings catalog sync.
// Idempotent so it is safe against a DB where keys were added manually. The API key
// itself is NOT here — secrets live in configuration (Columbia:ApiKey), not this table.
migrationBuilder.Sql(@"
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaSyncEnabled','false','Columbia Coatings Sync Enabled','Master switch for the scheduled Columbia Coatings catalog sync. When off, no automatic or manual sync runs regardless of the configured API key.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncIntervalDays')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaSyncIntervalDays','7','Columbia Sync Interval (days)','How many days between automatic Columbia catalog syncs. A full sync is cheap (~25 API calls), so daily (1) or weekly (7) keeps pricing fresh.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncedAt')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaLastSyncedAt',NULL,'Columbia Last Synced At','Timestamp (UTC) of the last successful Columbia catalog sync. Set automatically by the sync job.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncResult')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaLastSyncResult',NULL,'Columbia Last Sync Result','Summary of the last Columbia catalog sync run (inserted/updated/discontinued counts or error). Set automatically by the sync job.','Integrations');
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DELETE FROM PlatformSettings WHERE [Key] IN (
'ColumbiaSyncEnabled','ColumbiaSyncIntervalDays','ColumbiaLastSyncedAt','ColumbiaLastSyncResult'
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
}
}
}
@@ -7234,7 +7234,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644), CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7245,7 +7245,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651), CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7256,7 +7256,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652), CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -0,0 +1,143 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Constants;
using PowderCoating.Application.DTOs.Columbia;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// HTTP client for the Columbia Coatings product catalog API. Reads the API key and base URL from
/// configuration (<c>Columbia:ApiKey</c> / <c>Columbia:BaseUrl</c>), sends the <c>X-API-Key</c>
/// header, and pages the catalog via <c>GET /products</c>. Honors the documented rate limit
/// (120 requests / 60s) by retrying on HTTP 429 after the <c>Retry-After</c> interval.
/// </summary>
public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
{
private const int MaxRetriesPer429 = 5;
private const int DefaultRetryAfterSeconds = 5;
private const int MaxPagesSafetyCap = 1000; // guards against a server that never reports last page
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _config;
private readonly ILogger<ColumbiaCoatingsApiClient> _logger;
/// <summary>
/// Columbia returns snake_case JSON; the snake-case naming policy binds it to our PascalCase DTOs
/// without per-property attributes. Case-insensitive as a belt-and-braces fallback.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
};
public ColumbiaCoatingsApiClient(
IHttpClientFactory httpClientFactory,
IConfiguration config,
ILogger<ColumbiaCoatingsApiClient> logger)
{
_httpClientFactory = httpClientFactory;
_config = config;
_logger = logger;
}
private string? ApiKey => _config[ColumbiaIntegrationConstants.ConfigApiKey];
private string BaseUrl =>
(_config[ColumbiaIntegrationConstants.ConfigBaseUrl] ?? ColumbiaIntegrationConstants.DefaultBaseUrl)
.TrimEnd('/');
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
/// <inheritdoc />
public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
int page, int perPage, CancellationToken cancellationToken = default)
{
if (!IsConfigured)
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage);
var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?page={page}&per_page={perPage}";
for (var attempt = 1; ; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-API-Key", ApiKey);
var client = _httpClientFactory.CreateClient();
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
if (attempt > MaxRetriesPer429)
throw new HttpRequestException(
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries on page {page}.");
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds;
_logger.LogWarning(
"Columbia API returned 429 on page {Page} (attempt {Attempt}); waiting {Delay}s before retry.",
page, attempt, delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
continue;
}
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
return result ?? new ColumbiaProductsResponse();
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
CancellationToken cancellationToken = default)
{
if (!IsConfigured)
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
var all = new List<ColumbiaProduct>();
for (var page = 1; page <= MaxPagesSafetyCap; page++)
{
var response = await GetProductsPageAsync(page, ColumbiaIntegrationConstants.MaxPerPage, cancellationToken);
if (response.Items.Count == 0)
break;
all.AddRange(response.Items);
// Stop when the pagination block says we've reached the last page.
if (response.Pagination is { TotalPages: > 0 } p && page >= p.TotalPages)
break;
}
_logger.LogInformation("Columbia API: retrieved {Count} products across paged requests.", all.Count);
return all;
}
/// <summary>
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds,
/// or null when absent/unparseable so the caller can fall back to a default.
/// </summary>
private static int? GetRetryAfterSeconds(HttpResponseMessage response)
{
var retryAfter = response.Headers.RetryAfter;
if (retryAfter == null)
return null;
if (retryAfter.Delta is { } delta)
return Math.Max(1, (int)Math.Ceiling(delta.TotalSeconds));
if (retryAfter.Date is { } date)
{
var seconds = (int)Math.Ceiling((date - DateTimeOffset.UtcNow).TotalSeconds);
return seconds > 0 ? seconds : 1;
}
return null;
}
}
+1
View File
@@ -222,6 +222,7 @@ builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>(
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>(); builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>(); builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddScoped<IColumbiaCoatingsApiClient, ColumbiaCoatingsApiClient>();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>(); builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>(); builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>(); builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
+4
View File
@@ -42,6 +42,10 @@
"ApiKey": "25651af3a4829559ef0dfa1758fa3edbf92b6b76" "ApiKey": "25651af3a4829559ef0dfa1758fa3edbf92b6b76"
} }
}, },
"Columbia": {
"ApiKey": "",
"BaseUrl": "https://columbiacoatings.com"
},
"SendGrid": { "SendGrid": {
"ApiKey": "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", "ApiKey": "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",
"FromEmail": "spouliot@scppowdercoating.com", "FromEmail": "spouliot@scppowdercoating.com",