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:
Generated
+11375
File diff suppressed because it is too large
Load Diff
+88
@@ -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,
|
||||
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",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7245,7 +7245,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
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",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7256,7 +7256,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
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",
|
||||
DiscountPercent = 10m,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user