27 KiB
SplashPage Updates para OSB Master Integration
Resumen
Este documento describe las modificaciones necesarias en las instancias de SplashPage para integrarse con el sistema OSB Master.
Fase 1: Modificaciones en SplashPage Backend
1.1 Crear API de Métricas
InstanceMetricsController.cs
Ubicación: src/SplashPage.Web.Host/Controllers/InstanceMetricsController.cs
Endpoints a implementar:
[Route("api/[controller]")]
public class InstanceMetricsController : SplashPageControllerBase
{
private readonly IRepository<SplashUser> _userRepo;
private readonly IRepository<SplashUserConnection> _connectionRepo;
private readonly IRepository<SplashMerakiNetwork> _networkRepo;
private readonly IRepository<SplashEmailTemplate> _emailTemplateRepo;
private readonly IRepository<SplashScheduledEmail> _scheduledEmailRepo;
private readonly ITenantCache _tenantCache;
[HttpGet("health")]
[AbpAllowAnonymous]
public async Task<IActionResult> GetHealth()
{
// Basic health check - no auth required
return Ok(new {
status = "healthy",
timestamp = DateTime.UtcNow,
version = AppVersionHelper.Version
});
}
[HttpGet("metrics")]
public async Task<InstanceMetricsDto> GetMetrics(
[FromHeader(Name = "X-Master-Api-Key")] string apiKey)
{
// Validate API key
var validKey = Environment.GetEnvironmentVariable("MASTER_API_KEY");
if (string.IsNullOrEmpty(apiKey) || apiKey != validKey)
throw new AbpAuthorizationException("Invalid API key");
var tenantId = AbpSession.TenantId ?? 0;
// Gather metrics (use service layer)
var metrics = await _metricsService.GetInstanceMetrics(tenantId);
return metrics;
}
}
1.2 Crear DTO de Métricas
InstanceMetricsDto.cs
Ubicación: src/SplashPage.Application/Metrics/Dto/InstanceMetricsDto.cs
public class InstanceMetricsDto
{
// Instance Info
public string InstanceId { get; set; }
public string TenantName { get; set; }
public int TenantId { get; set; }
public DateTime LastChecked { get; set; }
public string AppVersion { get; set; }
public string Environment { get; set; }
// User Metrics
public int TotalUsers { get; set; }
public int ActiveUsersLast24Hours { get; set; }
public int ActiveUsersLast7Days { get; set; }
public int OnlineUsersNow { get; set; }
public DateTime? LastUserLogin { get; set; }
public string LastUserEmail { get; set; }
// Admin Access
public DateTime? LastAdminAccess { get; set; }
public string LastAdminEmail { get; set; }
// Device/Network Metrics
public int TotalNetworks { get; set; }
public int TotalAccessPoints { get; set; }
public int MerakiOrganizationCount { get; set; }
// Email Metrics
public int EmailTemplatesCount { get; set; }
public int PendingScheduledEmails { get; set; }
public int EmailsSentLast24Hours { get; set; }
public DateTime? LastEmailSent { get; set; }
// System Health
public bool DatabaseConnected { get; set; }
public bool MerakiApiHealthy { get; set; }
public double DatabaseResponseTimeMs { get; set; }
}
1.3 Implementar Service de Métricas
InstanceMetricsAppService.cs
Ubicación: src/SplashPage.Application/Metrics/InstanceMetricsAppService.cs
public class InstanceMetricsAppService : SplashPageAppServiceBase
{
private readonly IRepository<SplashUser> _userRepo;
private readonly IRepository<SplashUserConnection> _connectionRepo;
private readonly IRepository<SplashMerakiNetwork> _networkRepo;
private readonly IRepository<SplashMerakiDevice> _deviceRepo;
private readonly IRepository<SplashMerakiOrganization> _orgRepo;
private readonly IRepository<SplashEmailTemplate> _emailTemplateRepo;
private readonly IRepository<SplashScheduledEmail> _scheduledEmailRepo;
private readonly ITenantCache _tenantCache;
private readonly IMemoryCache _cache;
public async Task<InstanceMetricsDto> GetInstanceMetrics(int tenantId)
{
// Check cache first (1 minute cache)
var cacheKey = $"InstanceMetrics_{tenantId}";
if (_cache.TryGetValue(cacheKey, out InstanceMetricsDto cachedMetrics))
{
return cachedMetrics;
}
var metrics = new InstanceMetricsDto
{
InstanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "unknown",
TenantId = tenantId,
TenantName = _tenantCache.Get(tenantId)?.TenancyName ?? "Unknown",
LastChecked = DateTime.UtcNow,
AppVersion = AppVersionHelper.Version,
Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"
};
// Run queries in parallel for better performance
var tasks = new[]
{
GetUserMetricsAsync(tenantId, metrics),
GetDeviceMetricsAsync(tenantId, metrics),
GetEmailMetricsAsync(tenantId, metrics),
GetSystemHealthAsync(metrics)
};
await Task.WhenAll(tasks);
// Cache for 1 minute
_cache.Set(cacheKey, metrics, TimeSpan.FromMinutes(1));
return metrics;
}
private async Task GetUserMetricsAsync(int tenantId, InstanceMetricsDto metrics)
{
// Total users
metrics.TotalUsers = await _userRepo.CountAsync(u => u.TenantId == tenantId);
// Active users last 24 hours
var last24h = DateTime.UtcNow.AddHours(-24);
metrics.ActiveUsersLast24Hours = await _connectionRepo.CountAsync(
c => c.TenantId == tenantId && c.LastSeen >= last24h
);
// Active users last 7 days
var last7d = DateTime.UtcNow.AddDays(-7);
metrics.ActiveUsersLast7Days = await _connectionRepo.CountAsync(
c => c.TenantId == tenantId && c.LastSeen >= last7d
);
// Online users now
metrics.OnlineUsersNow = await _connectionRepo.CountAsync(
c => c.TenantId == tenantId && c.Status == "Online"
);
// Last user login
var lastConnection = await _connectionRepo.GetAll()
.Where(c => c.TenantId == tenantId)
.OrderByDescending(c => c.LastSeen)
.FirstOrDefaultAsync();
if (lastConnection != null)
{
metrics.LastUserLogin = lastConnection.LastSeen;
var user = await _userRepo.GetAsync(lastConnection.SplashUserId);
metrics.LastUserEmail = user?.Email;
}
// Last admin access - from AbpUserLoginAttempts or AuditLogs
// This would need to query the ABP audit logs for admin users
// Implementation depends on your specific audit logging setup
}
private async Task GetDeviceMetricsAsync(int tenantId, InstanceMetricsDto metrics)
{
metrics.TotalNetworks = await _networkRepo.CountAsync(n => n.TenantId == tenantId);
metrics.TotalAccessPoints = await _deviceRepo.CountAsync(
d => d.TenantId == tenantId && d.Model.Contains("AP")
);
metrics.MerakiOrganizationCount = await _orgRepo.CountAsync(o => o.TenantId == tenantId);
}
private async Task GetEmailMetricsAsync(int tenantId, InstanceMetricsDto metrics)
{
metrics.EmailTemplatesCount = await _emailTemplateRepo.CountAsync(
t => t.TenantId == tenantId && t.IsActive
);
metrics.PendingScheduledEmails = await _scheduledEmailRepo.CountAsync(
e => e.TenantId == tenantId && e.Status == "Pending"
);
var last24h = DateTime.UtcNow.AddHours(-24);
metrics.EmailsSentLast24Hours = await _scheduledEmailRepo.CountAsync(
e => e.TenantId == tenantId &&
e.Status == "Sent" &&
e.SentDate >= last24h
);
var lastEmail = await _scheduledEmailRepo.GetAll()
.Where(e => e.TenantId == tenantId && e.Status == "Sent")
.OrderByDescending(e => e.SentDate)
.FirstOrDefaultAsync();
metrics.LastEmailSent = lastEmail?.SentDate;
}
private async Task GetSystemHealthAsync(InstanceMetricsDto metrics)
{
try
{
var sw = Stopwatch.StartNew();
await _userRepo.GetAll().AnyAsync();
sw.Stop();
metrics.DatabaseConnected = true;
metrics.DatabaseResponseTimeMs = sw.ElapsedMilliseconds;
}
catch
{
metrics.DatabaseConnected = false;
metrics.DatabaseResponseTimeMs = -1;
}
// Check Meraki API health by trying to fetch org info
// Implementation depends on your Meraki service
metrics.MerakiApiHealthy = true; // Placeholder
}
}
1.4 Módulo de Buzón de Sugerencias
1.4.1 Entidad - SuggestionBox.cs
Ubicación: src/SplashPage.Core/Feedback/SuggestionBox.cs
[Table("SuggestionBoxes")]
public class SuggestionBox : FullAuditedEntity, IMustHaveTenant
{
public int TenantId { get; set; }
public long UserId { get; set; }
[ForeignKey("UserId")]
public virtual User User { get; set; }
[Required]
[StringLength(50)]
public string Category { get; set; } // Bug, Feature, Improvement, Performance, UI/UX, Documentation, Other
[Required]
[StringLength(200)]
public string Subject { get; set; }
[Required]
[StringLength(5000)]
public string Description { get; set; }
[StringLength(50)]
public string Status { get; set; } // Pending, InReview, Resolved, Rejected
[StringLength(20)]
public string Priority { get; set; } // Low, Medium, High
public DateTime? ResolvedDate { get; set; }
[StringLength(2000)]
public string AdminResponse { get; set; }
public long? ResolvedBy { get; set; }
public bool SentToMaster { get; set; }
public DateTime? SentToMasterDate { get; set; }
}
1.4.2 DbContext - Agregar DbSet
Ubicación: src/SplashPage.EntityFrameworkCore/EntityFrameworkCore/SplashPageDbContext.cs
public DbSet<SuggestionBox> SuggestionBoxes { get; set; }
1.4.3 Application Service - SuggestionBoxAppService.cs
Ubicación: src/SplashPage.Application/Feedback/SuggestionBoxAppService.cs
public class SuggestionBoxAppService : AsyncCrudAppService<
SuggestionBox,
SuggestionBoxDto,
int,
PagedSuggestionBoxResultRequestDto,
CreateSuggestionBoxDto,
SuggestionBoxDto>
{
private readonly IHttpClientFactory _httpClientFactory;
public SuggestionBoxAppService(
IRepository<SuggestionBox> repository,
IHttpClientFactory httpClientFactory) : base(repository)
{
_httpClientFactory = httpClientFactory;
}
[AbpAuthorize]
public override async Task<SuggestionBoxDto> CreateAsync(CreateSuggestionBoxDto input)
{
// Create locally
var suggestion = ObjectMapper.Map<SuggestionBox>(input);
suggestion.UserId = AbpSession.GetUserId();
suggestion.Status = "Pending";
suggestion.Priority = "Medium"; // Default
suggestion.SentToMaster = false;
var id = await Repository.InsertAndGetIdAsync(suggestion);
await CurrentUnitOfWork.SaveChangesAsync();
var created = await Repository.GetAsync(id);
// Send to Master via webhook (fire and forget)
_ = SendToMasterAsync(created);
return MapToEntityDto(created);
}
[AbpAuthorize]
public async Task<PagedResultDto<SuggestionBoxDto>> GetMySuggestions(
PagedSuggestionBoxResultRequestDto input)
{
var userId = AbpSession.GetUserId();
var query = Repository.GetAll()
.Where(s => s.UserId == userId);
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(s => s.CreationTime)
.PageBy(input)
.ToListAsync();
return new PagedResultDto<SuggestionBoxDto>(
totalCount,
ObjectMapper.Map<List<SuggestionBoxDto>>(items)
);
}
[AbpAuthorize("Pages.Administration")]
public async Task<PagedResultDto<SuggestionBoxDto>> GetAllSuggestions(
PagedSuggestionBoxResultRequestDto input)
{
var query = Repository.GetAll()
.WhereIf(!string.IsNullOrEmpty(input.Status), s => s.Status == input.Status)
.WhereIf(!string.IsNullOrEmpty(input.Category), s => s.Category == input.Category);
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(s => s.CreationTime)
.PageBy(input)
.ToListAsync();
return new PagedResultDto<SuggestionBoxDto>(
totalCount,
ObjectMapper.Map<List<SuggestionBoxDto>>(items)
);
}
private async Task SendToMasterAsync(SuggestionBox suggestion)
{
try
{
var masterUrl = Environment.GetEnvironmentVariable("MASTER_URL");
if (string.IsNullOrEmpty(masterUrl))
return;
var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID");
var apiKey = Environment.GetEnvironmentVariable("MASTER_API_KEY");
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Master-Api-Key", apiKey);
var payload = new
{
instanceId = instanceId,
tenantName = suggestion.TenantId, // Get tenant name from cache
userId = suggestion.UserId,
userEmail = suggestion.User?.EmailAddress,
userName = suggestion.User?.FullName,
category = suggestion.Category,
subject = suggestion.Subject,
description = suggestion.Description,
timestamp = suggestion.CreationTime
};
var response = await client.PostAsJsonAsync(
$"{masterUrl}/api/suggestions/submit",
payload
);
if (response.IsSuccessStatusCode)
{
suggestion.SentToMaster = true;
suggestion.SentToMasterDate = DateTime.UtcNow;
await Repository.UpdateAsync(suggestion);
}
}
catch (Exception ex)
{
Logger.Error("Failed to send suggestion to Master", ex);
// Don't throw - this is a background operation
}
}
}
1.4.4 DTOs
Ubicación: src/SplashPage.Application/Feedback/Dto/
public class SuggestionBoxDto : EntityDto
{
public int TenantId { get; set; }
public long UserId { get; set; }
public string UserName { get; set; }
public string UserEmail { get; set; }
public string Category { get; set; }
public string Subject { get; set; }
public string Description { get; set; }
public string Status { get; set; }
public string Priority { get; set; }
public DateTime? ResolvedDate { get; set; }
public string AdminResponse { get; set; }
public DateTime CreationTime { get; set; }
}
public class CreateSuggestionBoxDto
{
[Required]
public string Category { get; set; }
[Required]
[StringLength(200)]
public string Subject { get; set; }
[Required]
[StringLength(5000)]
public string Description { get; set; }
}
public class PagedSuggestionBoxResultRequestDto : PagedResultRequestDto
{
public string Status { get; set; }
public string Category { get; set; }
}
1.5 UI del Buzón de Sugerencias (Next.js)
1.5.1 Ruta de Feedback
Ubicación: src/SplashPage.Web.Ui/src/app/dashboard/feedback/page.tsx
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useCreateSuggestionMutation, useGetMySuggestionsQuery } from '@/api/hooks';
const formSchema = z.object({
category: z.string().min(1, 'Categoría requerida'),
subject: z.string().min(5, 'Mínimo 5 caracteres').max(200),
description: z.string().min(20, 'Mínimo 20 caracteres').max(5000),
});
export default function FeedbackPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
category: '',
subject: '',
description: '',
},
});
const createMutation = useCreateSuggestionMutation();
const { data: mySuggestions, refetch } = useGetMySuggestionsQuery({
maxResultCount: 20,
skipCount: 0,
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await createMutation.mutateAsync(values);
form.reset();
refetch();
} catch (error) {
console.error('Error creating suggestion:', error);
}
};
const categories = [
'Bug',
'Feature Request',
'Improvement',
'Performance',
'UI/UX',
'Documentation',
'Other',
];
const getStatusColor = (status: string) => {
switch (status) {
case 'Pending':
return 'bg-yellow-500';
case 'InReview':
return 'bg-blue-500';
case 'Resolved':
return 'bg-green-500';
case 'Rejected':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
return (
<div className="container mx-auto p-6 space-y-6">
<h1 className="text-3xl font-bold">Buzón de Sugerencias</h1>
{/* Form */}
<Card>
<CardHeader>
<CardTitle>Nueva Sugerencia</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Categoría</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Selecciona una categoría" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Asunto</FormLabel>
<FormControl>
<Input placeholder="Breve descripción del tema" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea
placeholder="Describe tu sugerencia en detalle..."
rows={6}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Enviando...' : 'Enviar Sugerencia'}
</Button>
</form>
</Form>
</CardContent>
</Card>
{/* History */}
<Card>
<CardHeader>
<CardTitle>Mis Sugerencias</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{mySuggestions?.items?.map((suggestion) => (
<div
key={suggestion.id}
className="border rounded-lg p-4 space-y-2"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold">{suggestion.subject}</h3>
<Badge className={getStatusColor(suggestion.status)}>
{suggestion.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{suggestion.description}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{suggestion.category}</span>
<span>•</span>
<span>{new Date(suggestion.creationTime).toLocaleDateString()}</span>
</div>
{suggestion.adminResponse && (
<div className="mt-2 p-2 bg-blue-50 rounded">
<p className="text-sm font-semibold">Respuesta del Admin:</p>
<p className="text-sm">{suggestion.adminResponse}</p>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
1.5.2 Botón Flotante (Componente Global)
Ubicación: src/SplashPage.Web.Ui/src/components/feedback-button.tsx
'use client';
import { MessageSquarePlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
export function FeedbackButton() {
const router = useRouter();
return (
<Button
onClick={() => router.push('/dashboard/feedback')}
className="fixed bottom-6 right-6 rounded-full w-14 h-14 shadow-lg"
size="icon"
title="Enviar sugerencia"
>
<MessageSquarePlus className="h-6 w-6" />
</Button>
);
}
Agregar a Layout: src/SplashPage.Web.Ui/src/app/dashboard/layout.tsx
import { FeedbackButton } from '@/components/feedback-button';
export default function DashboardLayout({ children }) {
return (
<>
{children}
<FeedbackButton />
</>
);
}
1.6 Variables de Entorno
Ubicación: Agregar a appsettings.json o variables de entorno del servidor
{
"Master": {
"ApiKey": "your-secure-api-key-here",
"InstanceId": "lc-prod",
"MasterUrl": "https://master.osb.beprime.mx",
"ReportingEnabled": true
}
}
Variables de Entorno:
MASTER_API_KEY=your-secure-api-key-here
INSTANCE_ID=lc-prod
MASTER_URL=https://master.osb.beprime.mx
1.7 Auto-Enrollment Worker (Opcional)
Ubicación: src/SplashPage.Application/BackgroundWorkers/MasterEnrollmentWorker.cs
public class MasterEnrollmentWorker : PeriodicBackgroundWorkerBase
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ITenantCache _tenantCache;
public MasterEnrollmentWorker(
AbpTimer timer,
IHttpClientFactory httpClientFactory,
ITenantCache tenantCache) : base(timer)
{
Timer.Period = 3600000; // Run once per hour
_httpClientFactory = httpClientFactory;
_tenantCache = tenantCache;
}
protected override async Task DoWorkAsync()
{
var masterUrl = Environment.GetEnvironmentVariable("MASTER_URL");
if (string.IsNullOrEmpty(masterUrl))
return;
try
{
var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID");
var apiKey = Environment.GetEnvironmentVariable("MASTER_API_KEY");
var tenantId = AbpSession.TenantId ?? 1;
var tenant = _tenantCache.Get(tenantId);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Master-Api-Key", apiKey);
var payload = new
{
instanceId = instanceId,
baseUrl = "https://" + Environment.GetEnvironmentVariable("DOMAIN"),
tenantId = tenantId,
tenantName = tenant?.TenancyName,
environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
appVersion = AppVersionHelper.Version,
contactEmail = tenant?.AdminEmailAddress
};
var response = await client.PostAsJsonAsync(
$"{masterUrl}/api/onboarding/enroll",
payload
);
if (response.IsSuccessStatusCode)
{
Logger.Info("Successfully enrolled/updated with Master");
}
}
catch (Exception ex)
{
Logger.Error("Failed to enroll with Master", ex);
}
}
}
Migración de Base de Datos
Crear Migración
cd src/SplashPage.EntityFrameworkCore
dotnet ef migrations add AddSuggestionBox --startup-project ../SplashPage.Web.Host
Aplicar Migración
cd src/SplashPage.Migrator
dotnet run
O manualmente:
cd src/SplashPage.EntityFrameworkCore
dotnet ef database update --startup-project ../SplashPage.Web.Host
Testing
1. Test Health Endpoint
curl https://your-instance.com/api/InstanceMetrics/health
Expected: { "status": "healthy", "timestamp": "..." }
2. Test Metrics Endpoint
curl -H "X-Master-Api-Key: your-api-key" \
https://your-instance.com/api/InstanceMetrics/metrics
Expected: Full metrics JSON
3. Test Suggestion Creation
- Login to dashboard
- Navigate to
/dashboard/feedback - Fill form and submit
- Check database for new record
- Verify webhook was sent to Master (check logs)
Checklist de Implementación
- Crear InstanceMetricsController
- Crear InstanceMetricsDto
- Implementar InstanceMetricsAppService
- Crear entidad SuggestionBox
- Agregar DbSet a DbContext
- Crear SuggestionBoxAppService
- Crear DTOs de Suggestion
- Crear migración de base de datos
- Aplicar migración
- Crear UI de feedback en Next.js
- Agregar botón flotante al layout
- Configurar variables de entorno
- (Opcional) Implementar MasterEnrollmentWorker
- Probar health endpoint
- Probar metrics endpoint con API key
- Probar creación de sugerencias
- Verificar webhook a Master
Notas de Seguridad
- API Key: Nunca commitear el API key en el código. Usar variables de entorno o Azure Key Vault.
- HTTPS: Asegurar que todas las comunicaciones con Master usen HTTPS.
- Rate Limiting: Considerar agregar rate limiting al metrics endpoint.
- Validation: Validar todos los inputs en SuggestionBox para prevenir XSS.
Soporte
Para preguntas sobre la implementación, contactar al equipo de desarrollo de OSB Master.