Recipe: DNN end-to-end
Esta guía es para integradores que trabajan con DNN Platform (antes DotNetNuke) y un módulo de e-commerce sobre .NET (DNN Commerce, NB_Store, Hotcakes o un módulo propio). Al terminar tendrás:
- Productos sincronizados desde tu BD a Neuroon (sync nocturno + delta).
- Widget de búsqueda renderizado en tu tema, con token rotado server-side.
- Tracking de conversiones server-to-server al cerrar venta.
Tiempo estimado: 45-60 min la primera vez.
Prerrequisitos
- DNN 9.x sobre .NET Framework 4.7.2+ o DNN sobre .NET 8 (módulos modernos).
- Acceso a la BD del módulo de productos (consulta SQL directa o repositorio del módulo).
- Permisos de Host para añadir HostSettings y un DesktopModule.
- Una Shop API Key generada desde el dashboard de Neuroon (Production o Development).
- NuGet:
Polly(retry) ySystem.Net.Http.Json(para .NET Framework necesitasSystem.Text.Json6.0+).
Paso 1. Almacenar credenciales en HostSettings
DNN ofrece HostController.Instance.Update("Key", "Value", true) para guardar settings cifrados. Crea cuatro entradas:
using DotNetNuke.Entities.Host;
HostController.Instance.Update("Neuroon.ShopId", "shop_xxxxxxxx", true);
HostController.Instance.Update("Neuroon.ApiKey", "sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true);
HostController.Instance.Update("Neuroon.ApiUrl", "https://dev-api.neuroon.ai", true);
HostController.Instance.Update("Neuroon.WidgetTokenCache", "", true);
Y un helper para leerlas:
public static class NeuroonSettings
{
public static string ShopId => HostController.Instance.GetString("Neuroon.ShopId");
public static string ApiKey => HostController.Instance.GetEncryptedString("Neuroon.ApiKey", Config.GetDecryptionkey());
public static string ApiUrl => HostController.Instance.GetString("Neuroon.ApiUrl", "https://api.neuroon.ai");
}
Paso 2. Cliente HTTP reutilizable
Crea un NeuroonClient con retry e idempotencia. Coloca el archivo en el módulo, p. ej. App_Code/Neuroon/NeuroonClient.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Polly;
using Polly.Retry;
public sealed class NeuroonClient
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
private readonly string _shopId;
private readonly AsyncRetryPolicy<HttpResponseMessage> _retry = Policy
.HandleResult<HttpResponseMessage>(r => (int)r.StatusCode == 429 || (int)r.StatusCode >= 500)
.Or<HttpRequestException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
public NeuroonClient()
{
_http = new HttpClient { BaseAddress = new Uri(NeuroonSettings.ApiUrl), Timeout = TimeSpan.FromSeconds(45) };
_http.DefaultRequestHeaders.Add("X-Shop-API-Key", NeuroonSettings.ApiKey);
_http.DefaultRequestHeaders.Add("User-Agent", "DNN-Neuroon/1.0");
_shopId = NeuroonSettings.ShopId;
}
public async Task<SyncResult> SyncProductsAsync(SyncMode mode, IEnumerable<NeuroonProduct> products)
{
var aggregate = new SyncResult();
foreach (var batch in Chunk(products, 500))
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsJsonAsync(
$"/api/plugin/shops/{_shopId}/products/sync",
new { syncType = mode.ToString(), products = batch },
JsonOpts));
resp.EnsureSuccessStatusCode();
aggregate.Merge(await resp.Content.ReadFromJsonAsync<SyncResult>(JsonOpts));
}
return aggregate;
}
public async Task TrackConversionAsync(string orderId, decimal orderValue, string currency,
IEnumerable<ConversionLine> lines)
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsJsonAsync(
$"/api/plugin/shops/{_shopId}/track/conversion",
new { orderId, orderValue, currency, conversions = lines },
JsonOpts));
resp.EnsureSuccessStatusCode();
}
public async Task<string> IssueWidgetTokenAsync()
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsync(
$"/api/shops/{_shopId}/widget-token", content: null));
resp.EnsureSuccessStatusCode();
var payload = await resp.Content.ReadFromJsonAsync<WidgetTokenResponse>(JsonOpts);
return payload.Token;
}
private static IEnumerable<List<T>> Chunk<T>(IEnumerable<T> source, int size)
{
var bucket = new List<T>(size);
foreach (var item in source)
{
bucket.Add(item);
if (bucket.Count == size) { yield return bucket; bucket = new List<T>(size); }
}
if (bucket.Count > 0) yield return bucket;
}
public enum SyncMode { INCREMENTAL, FULL }
public sealed class SyncResult
{
public int TotalReceived { get; set; }
public int NewProducts { get; set; }
public int UpdatedProducts { get; set; }
public int Skipped { get; set; }
public int Failed { get; set; }
public List<SyncError> Errors { get; set; } = new();
public int ProductsCount { get; set; }
public int RemainingProducts { get; set; }
public void Merge(SyncResult other)
{
TotalReceived += other.TotalReceived;
NewProducts += other.NewProducts;
UpdatedProducts += other.UpdatedProducts;
Skipped += other.Skipped;
Failed += other.Failed;
Errors.AddRange(other.Errors ?? Enumerable.Empty<SyncError>());
ProductsCount = other.ProductsCount;
RemainingProducts = other.RemainingProducts;
}
}
public sealed class SyncError { public string ExternalId { get; set; } public string Error { get; set; } }
public sealed class ConversionLine { public string ProductId { get; set; } public int Quantity { get; set; } public decimal LineTotal { get; set; } }
public sealed class WidgetTokenResponse { public string Token { get; set; } }
}
Paso 3. Mapeo desde tu BD
Adapta este DTO a tu modelo. La estructura es la del endpoint products/sync:
public sealed record NeuroonProduct
{
public string ExternalId { get; init; } // tu PK / SKU
public string Sku { get; init; }
public string Name { get; init; } // máx 512
public string Description { get; init; } // máx 5000
public decimal Price { get; init; }
public string Currency { get; init; } = "EUR"; // ISO 4217
public string Url { get; init; }
public string ImageUrl { get; init; }
public IReadOnlyList<string> Categories { get; init; }
public IReadOnlyList<string> Brands { get; init; }
public bool? InStock { get; init; }
public int? StockQuantity { get; init; }
public decimal? Rating { get; init; }
public int? ReviewCount { get; init; }
public bool? IsActive { get; init; } = true;
public IReadOnlyList<string> Tags { get; init; }
public decimal? SalePrice { get; init; }
public string ParentExternalId { get; init; }
public IReadOnlyDictionary<string, string> Attributes { get; init; }
}
Validaciones que aplica el backend (referencia): externalId, name, price, url son obligatorios; name máx 512, description máx 5000, price mayor o igual que 0, currency ISO 4217, rating entre 0 y 5.
Paso 4. Sync nocturno como IScheduledTask
DNN ejecuta tareas planificadas mediante el Scheduler. Crea una clase que herede de SchedulerClient:
using DotNetNuke.Services.Scheduling;
public class NeuroonSyncScheduler : SchedulerClient
{
public NeuroonSyncScheduler(ScheduleHistoryItem oItem) : base() { ScheduleHistoryItem = oItem; }
public override void DoWork()
{
try
{
Progressing();
var client = new NeuroonClient();
var products = LoadProductsFromCommerceModule();
var result = client.SyncProductsAsync(NeuroonClient.SyncMode.INCREMENTAL, products)
.GetAwaiter().GetResult();
ScheduleHistoryItem.AddLogNote(
$"Synced {result.NewProducts} new, {result.UpdatedProducts} updated, " +
$"{result.Failed} failed, remaining quota {result.RemainingProducts}");
ScheduleHistoryItem.Succeeded = true;
}
catch (Exception ex)
{
ScheduleHistoryItem.Succeeded = false;
ScheduleHistoryItem.AddLogNote("Neuroon sync failed: " + ex.Message);
Errored(ref ex);
}
}
private static IEnumerable<NeuroonProduct> LoadProductsFromCommerceModule()
{
// Ejemplo NB_Store (adapta a tu módulo):
// foreach (var p in NBrightBuy.GetActiveProducts(portalId)) yield return Map(p);
throw new NotImplementedException("Implementa el mapeo desde tu módulo de e-commerce.");
}
}
Registra la tarea con frecuencia 1 day desde Host → Schedule → Add Item. Para sync delta más frecuente (cada hora, por ejemplo), filtra productos por LastUpdatedDate > DateTime.UtcNow.AddHours(-1).
Paso 5. Emitir widget tokens server-side
El Widget Token dura 24 h y se inyecta en el <script> del frontend. Para no llamar al API en cada petición, cachea el token:
public static class WidgetTokenProvider
{
private static readonly object Lock = new();
private static string _token;
private static DateTime _expires;
public static string Get()
{
lock (Lock)
{
if (_token != null && DateTime.UtcNow < _expires.AddMinutes(-5))
return _token;
var fresh = new NeuroonClient().IssueWidgetTokenAsync().GetAwaiter().GetResult();
_token = fresh;
_expires = DateTime.UtcNow.AddHours(24);
return _token;
}
}
}
Paso 6. Embebido en el tema (Skin / SkinObject)
En el archivo .ascx de tu Skin (o como SkinObject reutilizable), añade:
<%@ Control Language="C#" AutoEventWireup="true" %>
<%@ Register TagPrefix="dnn" Namespace="DotNetNuke.UI.Skins.Controls" Assembly="DotNetNuke" %>
<asp:PlaceHolder runat="server">
<div id="neuroon-search"></div>
<script
src="https://cdn.neuroon.ai/widget.js"
data-token='<%= WidgetTokenProvider.Get() %>'
data-container="#neuroon-search"
data-theme="auto"
data-locale='<%= System.Threading.Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName %>'
data-api-url='<%= NeuroonSettings.ApiUrl %>'
crossorigin="anonymous">
</script>
</asp:PlaceHolder>
Para producción fija una versión exacta y añade integrity con el hash SRI publicado en https://cdn.neuroon.ai/widget@VERSION/sri-manifest.json.
Paso 7. Cart bridge (sincroniza el carrito con el widget)
El widget escucha eventos neuroon:cart-update y ajusta sus respuestas (cross-sell, validaciones de "ya en el carrito"). En tu vista del carrito:
// Tras anyadir o quitar producto en DNN Commerce / NB_Store:
window.dispatchEvent(new CustomEvent('neuroon:cart-update', {
detail: {
items: [
{ productId: '@Model.Sku', name: '@Model.Name', price: @Model.Price, quantity: @Model.Quantity }
],
total: @Model.Total,
currency: 'EUR'
}
}));
Si tu módulo dispara eventos jQuery, engancha el dispatch al evento equivalente y aplica debounce 200 ms para evitar floods.
Paso 8. Tracking de conversiones
Al confirmar pedido, llama server-side al endpoint de conversiones (no confíes solo en el JS por adblockers):
public void OnOrderConfirmed(Order order)
{
var client = new NeuroonClient();
client.TrackConversionAsync(
orderId: order.Id.ToString(),
orderValue: order.Total,
currency: order.Currency,
lines: order.Lines.Select(l => new NeuroonClient.ConversionLine
{
ProductId = l.Sku,
SearchLogId = l.SearchLogId, // requerido — leelo de la cookie del widget
Quantity = l.Quantity,
LineTotal = l.Price * l.Quantity
})
).GetAwaiter().GetResult();
}
SearchLogIdes@NotBlankenConversionItem(ShopRequestDTO.java:92). El widget lo escribe como cookie en el navegador del comprador en cada click sobre un resultado. Tu front-end de DNN tiene que reenviarlo al backend con cada línea de pedido (oculto en el formulario de checkout o vía un AJAX previo a la confirmación). Sin él el API responde 400.
Paso 9. Verificar la integración
# 1) Comprueba que la sync llego
curl -H "X-Shop-API-Key: $NEUROON_API_KEY" \
"https://dev-api.neuroon.ai/api/plugin/shops/$NEUROON_SHOP_ID/products?size=5"
# 2) Lanza una busqueda de prueba con el widget token
curl -H "X-Widget-Token: $TOKEN" \
"https://dev-api.neuroon.ai/api/widget/search?q=camiseta&limit=3"
# 3) Inspecciona el shop info y la cuota
curl -H "X-Shop-API-Key: $NEUROON_API_KEY" \
"https://dev-api.neuroon.ai/api/plugin/shops/me"
O abre directamente el playground de la API y prueba con tu propia API key (toggle Production/Development en la cabecera).
Errores frecuentes en DNN
| Síntoma | Causa | Solución |
|---|---|---|
401 Unauthorized en /products/sync | API key vacía o inválida | Verifica HostController.GetEncryptedString y la decryption key del web.config. |
403 Forbidden solo en navegador | Origin header no coincide | Server-to-server desde IScheduledTask no envía Origin (correcto). Si tu request viene de un proxy en el cliente, añade el dominio al shop. |
429 Too Many Requests | Rate limit por plan superado | Implementa backoff (Polly ya lo hace) y reparte syncs grandes en lotes de 500 con pausas. |
name: Size must be between | name excede 512 caracteres | Trunca o normaliza títulos. |
| Widget no carga, error CSP | Política CSP del Skin bloquea CDN | Añade cdn.neuroon.ai y dev-api.neuroon.ai (o api.neuroon.ai) a script-src y connect-src. |
| Token expirado | Cache local de widget token expiró | El cache del paso 5 ya gestiona esto; revisa el helper WidgetTokenProvider. |
Siguientes pasos
- Authentication · Widget token — flujo completo y rotación.
- API · products-sync — referencia exhaustiva del endpoint con playground.
- Recipe · Server-to-server token — generar tokens desde otros stacks.
- Reference · Errores — catálogo de códigos de error.