Saltar al contenido principal

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:

  1. Productos sincronizados desde tu BD a Neuroon (sync nocturno + delta).
  2. Widget de búsqueda renderizado en tu tema, con token rotado server-side.
  3. 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) y System.Net.Http.Json (para .NET Framework necesitas System.Text.Json 6.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();
}

SearchLogId es @NotBlank en ConversionItem (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íntomaCausaSolución
401 Unauthorized en /products/syncAPI key vacía o inválidaVerifica HostController.GetEncryptedString y la decryption key del web.config.
403 Forbidden solo en navegadorOrigin header no coincideServer-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 RequestsRate limit por plan superadoImplementa backoff (Polly ya lo hace) y reparte syncs grandes en lotes de 500 con pausas.
name: Size must be betweenname excede 512 caracteresTrunca o normaliza títulos.
Widget no carga, error CSPPolítica CSP del Skin bloquea CDNAñade cdn.neuroon.ai y dev-api.neuroon.ai (o api.neuroon.ai) a script-src y connect-src.
Token expiradoCache local de widget token expiróEl cache del paso 5 ya gestiona esto; revisa el helper WidgetTokenProvider.

Siguientes pasos