@@ -87,9 +87,12 @@ func Migrate() {
|
||||
// Sistema de notificaciones por evento
|
||||
&models.NotifEventoConfig{},
|
||||
&models.SistemaNotificacion{},
|
||||
&models.ServidorAlertaUmbral{},
|
||||
// Submódulo Partner
|
||||
&models.PartnerRecurso{},
|
||||
&models.PartnerComunicado{},
|
||||
// Integración VCard API Admin (Laravel)
|
||||
&models.VcardApiConfig{},
|
||||
); err != nil {
|
||||
log.Fatalf("Error during main migration: %v", err)
|
||||
}
|
||||
@@ -422,6 +425,7 @@ func SeedIntegraciones() {
|
||||
entries := []struct{ title, desc, url string }{
|
||||
{"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"},
|
||||
{"Cloudflare", "Gestión de zonas, DNS, SSL y firewall en Cloudflare", "/app/cloudflare"},
|
||||
{"VCard API", "Integración con el sistema VCard externo (Laravel + Sanctum): usuarios, membresías, vcards, pagos y más", "/app/vcard-api"},
|
||||
}
|
||||
|
||||
var insertados []models.Submodules
|
||||
@@ -736,6 +740,7 @@ func MigratePortal() {
|
||||
&models.ProyectoFase{},
|
||||
&models.ProyectoAvance{},
|
||||
&models.ProyectoEntregable{},
|
||||
&models.ProyectoDocumento{},
|
||||
&models.ProyectoTicket{},
|
||||
&models.TicketMensaje{},
|
||||
&models.PortalUser{},
|
||||
|
||||
@@ -75,8 +75,12 @@
|
||||
<p class="text-sm text-slate-600 mt-1" x-text="av.contenido"></p>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-4">
|
||||
<button @click="toggleAvanceVisible(av)" :title="av.visible ? 'Ocultar' : 'Publicar'" class="btn-icon" :class="av.visible ? 'text-green-500' : 'text-slate-400'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
<button @click="toggleAvanceVisible(av)"
|
||||
:title="av.visible ? 'Visible en portal · clic para ocultar' : 'Oculto en portal · clic para publicar'"
|
||||
class="btn-icon flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium border transition-colors"
|
||||
:class="av.visible ? 'text-green-600 border-green-200 bg-green-50 hover:bg-green-100' : 'text-slate-400 border-slate-200 bg-slate-50 hover:bg-slate-100'">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
<span x-text="av.visible ? 'Visible' : 'Oculto'"></span>
|
||||
</button>
|
||||
<button @click="deleteAvance(av.ID)" class="btn-icon text-red-400"><svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
|
||||
</div>
|
||||
@@ -306,6 +310,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div x-show="toast.show" x-cloak x-transition
|
||||
class="fixed bottom-6 right-6 z-50 px-5 py-3 rounded-xl shadow-lg text-white text-sm font-medium flex items-center gap-2"
|
||||
:class="toast.type === 'error' ? 'bg-red-500' : 'bg-[#8eb02f]'">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path x-show="toast.type !== 'error'" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
<path x-show="toast.type === 'error'" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<span x-text="toast.msg"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -325,6 +340,7 @@ function proyectoDetalle(proyectoId, slug) {
|
||||
showDocumentoModal: false,
|
||||
documentoForm: { tipo:'contrato', nombre:'', descripcion:'' },
|
||||
documentoFile: null, documentoError:'',
|
||||
toast: { show: false, msg: '', type: 'ok' },
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadFases(), this.loadAvances(), this.loadEntregables(), this.loadTickets(), this.loadDocumentos()]);
|
||||
@@ -390,8 +406,10 @@ function proyectoDetalle(proyectoId, slug) {
|
||||
await this.loadAvances();
|
||||
},
|
||||
async toggleAvanceVisible(av) {
|
||||
await axios.put(`/app/proyectos/${this.proyectoId}/avances/${av.ID}`, {...av, visible:!av.visible});
|
||||
const nuevoEstado = !av.visible;
|
||||
await axios.put(`/app/proyectos/${this.proyectoId}/avances/${av.ID}`, {...av, visible:nuevoEstado});
|
||||
await this.loadAvances();
|
||||
this.showToast(nuevoEstado ? 'Avance visible en el portal del cliente' : 'Avance oculto en el portal del cliente');
|
||||
},
|
||||
|
||||
// ─── Entregables ───
|
||||
@@ -462,6 +480,11 @@ function proyectoDetalle(proyectoId, slug) {
|
||||
t._reply='';
|
||||
},
|
||||
|
||||
showToast(msg, type = 'ok') {
|
||||
this.toast = { show: true, msg, type };
|
||||
setTimeout(() => { this.toast.show = false; }, 3000);
|
||||
},
|
||||
|
||||
// ─── Helpers ───
|
||||
faseBadgeClass(e) { return {pendiente:'badge-slate',en_progreso:'badge-yellow',completado:'badge-green',bloqueado:'badge-red'}[e]||'badge-slate'; },
|
||||
avanceBadgeClass(t) { return {update:'badge-blue',milestone:'badge-green',nota:'badge-slate',alerta:'badge-yellow'}[t]||'badge-slate'; },
|
||||
|
||||
@@ -55,12 +55,6 @@
|
||||
<span x-show="!webhookLoading[cfg.ID]">🔗 Webhook portal</span>
|
||||
<span x-show="webhookLoading[cfg.ID]">Registrando…</span>
|
||||
</button>
|
||||
<button @click="checkWebhook(cfg)" :disabled="webhookInfoLoading[cfg.ID]"
|
||||
class="flex-1 text-xs border border-slate-300 text-slate-600 px-3 py-1.5 rounded hover:bg-slate-50 transition-colors disabled:opacity-50"
|
||||
title="Consulta getWebhookInfo en Telegram para este bot">
|
||||
<span x-show="!webhookInfoLoading[cfg.ID]">📡 Estado webhook</span>
|
||||
<span x-show="webhookInfoLoading[cfg.ID]">Consultando…</span>
|
||||
</button>
|
||||
<button @click="openEdit(cfg)"
|
||||
class="text-xs border border-blue-400 text-blue-500 px-3 py-1.5 rounded hover:bg-blue-50">Editar</button>
|
||||
<button @click="confirmDelete(cfg.ID)"
|
||||
@@ -234,7 +228,6 @@ function telegramApp() {
|
||||
form: { nombre: '', bot_token: '', chat_id: '', notas: '', activo: true },
|
||||
testLoading: {}, testResult: {}, testOk: {},
|
||||
webhookLoading: {}, webhookResult: {}, webhookOk: {},
|
||||
webhookInfoLoading: {},
|
||||
sendForm: { config_ids: [], titulo: '', mensaje: '' },
|
||||
sendLoading: false, sendResult: '', sendOk: true,
|
||||
logs: [], logPage: 1, logTotalPages: 1, logTotal: 0,
|
||||
@@ -319,32 +312,6 @@ function telegramApp() {
|
||||
this.webhookLoading[cfg.ID] = false;
|
||||
}
|
||||
},
|
||||
async checkWebhook(cfg) {
|
||||
this.webhookInfoLoading[cfg.ID] = true;
|
||||
this.webhookResult[cfg.ID] = '';
|
||||
try {
|
||||
const res = await fetch(`/app/telegram/${cfg.ID}/webhook-info`);
|
||||
const d = await res.json();
|
||||
const info = d.result || {};
|
||||
const hasPortalURL = (info.url || '').includes('/webhooks/telegram-portal');
|
||||
const hasError = !!(info.last_error_message || info.last_synchronization_error_date);
|
||||
|
||||
this.webhookOk[cfg.ID] = !!d.ok && hasPortalURL && !hasError;
|
||||
|
||||
const parts = [];
|
||||
if (info.url) parts.push(`URL: ${info.url}`);
|
||||
if (typeof info.pending_update_count !== 'undefined') parts.push(`Pendientes: ${info.pending_update_count}`);
|
||||
if (info.last_error_message) parts.push(`Último error: ${info.last_error_message}`);
|
||||
if (!parts.length) parts.push(d.description || 'Sin datos de webhook');
|
||||
|
||||
this.webhookResult[cfg.ID] = (this.webhookOk[cfg.ID] ? '✅ ' : '⚠️ ') + parts.join(' | ');
|
||||
} catch (e) {
|
||||
this.webhookOk[cfg.ID] = false;
|
||||
this.webhookResult[cfg.ID] = '❌ Error consultando webhook: ' + e.message;
|
||||
} finally {
|
||||
this.webhookInfoLoading[cfg.ID] = false;
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
this.sendResult = '';
|
||||
|
||||
@@ -822,12 +822,6 @@ func PortalTelegramValidar(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "No autenticado"})
|
||||
}
|
||||
|
||||
// Si ya está vinculado (por ejemplo, vía webhook), responder éxito de inmediato.
|
||||
full, err := models.GetPortalUserByID(u.ID)
|
||||
if err == nil && full != nil && strings.TrimSpace(full.TelegramChatID) != "" {
|
||||
return c.JSON(fiber.Map{"ok": true, "chat_id": full.TelegramChatID})
|
||||
}
|
||||
|
||||
// Recuperar el token vigente del usuario
|
||||
tkn, err := models.GetTelegramPortalTokenByUser(u.ID)
|
||||
if err != nil || tkn == nil {
|
||||
@@ -835,15 +829,11 @@ func PortalTelegramValidar(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
configs, _ := models.GetAllTelegramConfigs()
|
||||
hadWebhookConflict := false
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Activo || cfg.BotToken == "" {
|
||||
continue
|
||||
}
|
||||
chatID, found, reason := searchTokenInUpdates(cfg.BotToken, tkn.Token)
|
||||
if reason == "webhook_conflict" {
|
||||
hadWebhookConflict = true
|
||||
}
|
||||
chatID, found := searchTokenInUpdates(cfg.BotToken, tkn.Token)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
@@ -858,36 +848,22 @@ func PortalTelegramValidar(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"ok": true, "chat_id": fmt.Sprintf("%d", chatID)})
|
||||
}
|
||||
|
||||
// Revalidar por si el webhook hizo la vinculación mientras corría esta petición.
|
||||
full, err = models.GetPortalUserByID(u.ID)
|
||||
if err == nil && full != nil && strings.TrimSpace(full.TelegramChatID) != "" {
|
||||
return c.JSON(fiber.Map{"ok": true, "chat_id": full.TelegramChatID})
|
||||
}
|
||||
|
||||
if hadWebhookConflict {
|
||||
return c.Status(200).JSON(fiber.Map{
|
||||
"ok": false,
|
||||
"error": "El bot está en modo webhook. Envía /vincular " + tkn.Token + " al bot y espera unos segundos; luego vuelve a verificar.",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "No se encontró el mensaje. Asegúrate de enviar /vincular " + tkn.Token + " al bot."})
|
||||
}
|
||||
|
||||
// searchTokenInUpdates llama getUpdates y busca un mensaje que contenga el token.
|
||||
// Retorna el chat_id y true si lo encuentra.
|
||||
func searchTokenInUpdates(botToken, token string) (int64, bool, string) {
|
||||
func searchTokenInUpdates(botToken, token string) (int64, bool) {
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)
|
||||
resp, err := http.Get(apiURL) //nolint:noctx
|
||||
if err != nil {
|
||||
log.Printf("[searchTokenInUpdates] error getUpdates: %v", err)
|
||||
return 0, false, "network_error"
|
||||
return 0, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
OK bool `json:"ok"`
|
||||
Result []struct {
|
||||
Message struct {
|
||||
Chat struct {
|
||||
@@ -897,18 +873,8 @@ func searchTokenInUpdates(botToken, token string) (int64, bool, string) {
|
||||
} `json:"message"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return 0, false, "decode_error"
|
||||
}
|
||||
if !result.OK {
|
||||
desc := strings.ToLower(strings.TrimSpace(result.Description))
|
||||
if strings.Contains(desc, "can't use getupdates method while webhook is active") {
|
||||
return 0, false, "webhook_conflict"
|
||||
}
|
||||
if desc != "" {
|
||||
log.Printf("[searchTokenInUpdates] Telegram API !ok: %s", result.Description)
|
||||
}
|
||||
return 0, false, "api_not_ok"
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || !result.OK {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
upperToken := strings.ToUpper(token)
|
||||
@@ -916,8 +882,8 @@ func searchTokenInUpdates(botToken, token string) (int64, bool, string) {
|
||||
txt := strings.ToUpper(strings.TrimSpace(upd.Message.Text))
|
||||
if strings.Contains(txt, upperToken) && upd.Message.Chat.ID != 0 {
|
||||
log.Printf("[searchTokenInUpdates] token=%s chatID=%d", token, upd.Message.Chat.ID)
|
||||
return upd.Message.Chat.ID, true, ""
|
||||
return upd.Message.Chat.ID, true
|
||||
}
|
||||
}
|
||||
return 0, false, ""
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -491,10 +492,25 @@ func DownloadDocumento(c *fiber.Ctx) error {
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName))
|
||||
nombre := item.OriginalName
|
||||
if nombre == "" {
|
||||
nombre = item.Nombre
|
||||
}
|
||||
c.Set("Content-Disposition", attachmentDisposition(nombre))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
|
||||
// attachmentDisposition genera un header Content-Disposition con fallback ASCII y encoding RFC 5987.
|
||||
func attachmentDisposition(name string) string {
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if r > 127 || r == '"' || r == '\\' || r == '/' || r == '\n' || r == '\r' {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, safe, url.PathEscape(name))
|
||||
}
|
||||
|
||||
// ─── Tickets (admin) ──────────────────────────────────────────────────────────
|
||||
|
||||
func GetTickets(c *fiber.Ctx) error {
|
||||
|
||||
+45
-1
@@ -21,7 +21,7 @@ func UserRoutes(app fiber.Router) {
|
||||
|
||||
// Rutas de la aplicación
|
||||
|
||||
// web me redireccione a /
|
||||
// web me redireccione a /
|
||||
protected.Get("/web", func(c *fiber.Ctx) error { return c.Redirect("/", http.StatusSeeOther) })
|
||||
app.Get("/web", func(c *fiber.Ctx) error { return c.Redirect("/", http.StatusSeeOther) })
|
||||
protected.Get("/", controllers.App)
|
||||
@@ -71,17 +71,23 @@ func UserRoutes(app fiber.Router) {
|
||||
|
||||
// Rutas de servidor
|
||||
protected.Get("/servidor", middlewares.MenuMiddleware, controllers.Servidor)
|
||||
protected.Get("/servidor-dashboard", middlewares.MenuMiddleware, controllers.ServidorDashboard)
|
||||
protected.Get("/loadservidorselect", controllers.GetServidor)
|
||||
protected.Get("/loadservidor", controllers.GetServidor)
|
||||
protected.Post("/servidor", controllers.CreateServidor)
|
||||
protected.Put("/servidor/:id", controllers.UpdateServidor)
|
||||
protected.Delete("/servidor/:id", controllers.DeleteServidor)
|
||||
protected.Get("/servidor-dashboard/:id", controllers.GetServidorDashboard)
|
||||
protected.Get("/conx-ping/:id", controllers.PingConexion)
|
||||
protected.Post("/servidor/:id/agent-token", controllers.GenerateAgentToken)
|
||||
protected.Post("/servidor/:id/sync-hostinger", controllers.SyncServidorFromHostinger)
|
||||
|
||||
// Rutas de proveedores de servidor
|
||||
protected.Get("/prov_servidor", middlewares.MenuMiddleware, controllers.ProvServidor)
|
||||
protected.Get("/loadprovservidor", controllers.GetProvServidor)
|
||||
protected.Post("/provservidor", controllers.CreateProvServidor)
|
||||
protected.Put("/provservidor/:id", controllers.UpdateProvServidor)
|
||||
protected.Put("/provservidor/:id/integraciones", controllers.SetProvServidorIntegraciones)
|
||||
protected.Delete("/provservidor/:id", controllers.DeleteProvServidor)
|
||||
|
||||
// Rutas de tipos de servidor
|
||||
@@ -215,6 +221,41 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/saas-api/logs", middlewares.MenuMiddleware, controllers.SaasDispatchLogIndex)
|
||||
protected.Get("/loadsaasdispatchlogs", controllers.GetSaasDispatchLogs)
|
||||
|
||||
// ─── VCard API (integración Admin Laravel) ────────────────────────────────
|
||||
protected.Get("/vcard-api", middlewares.MenuMiddleware, controllers.VcardApiIndex)
|
||||
protected.Post("/vcard-api/login", controllers.VcardApiLogin)
|
||||
protected.Get("/vcard-api/config", controllers.VcardApiGetConfig)
|
||||
protected.Post("/vcard-api/config", controllers.VcardApiSaveConfig)
|
||||
// Usuarios (GET)
|
||||
protected.Get("/vcard-api/usuarios", controllers.VcardApiUsuarios)
|
||||
protected.Get("/vcard-api/usuarios/:id/membresia", controllers.VcardApiMembresia)
|
||||
protected.Get("/vcard-api/usuarios/:userId/vcards", controllers.VcardApiVcardsByUsuario)
|
||||
protected.Get("/vcard-api/usuarios/:userId/pagos", controllers.VcardApiPagosByUsuario)
|
||||
protected.Get("/vcard-api/usuarios/:userId/transacciones", controllers.VcardApiTransaccionesByUsuario)
|
||||
protected.Get("/vcard-api/usuarios/:userId/logs", controllers.VcardApiLogsByUsuario)
|
||||
protected.Get("/vcard-api/usuarios/:userId/miniwebs", controllers.VcardApiMiniwebsByUsuario)
|
||||
protected.Get("/vcard-api/usuarios/:id", controllers.VcardApiUsuario)
|
||||
// Usuarios (mutaciones)
|
||||
protected.Put("/vcard-api/usuarios/:id", controllers.VcardApiUsuarioUpdate)
|
||||
protected.Post("/vcard-api/usuarios/:id/activar", controllers.VcardApiUsuarioActivar)
|
||||
protected.Post("/vcard-api/usuarios/:id/desactivar", controllers.VcardApiUsuarioDesactivar)
|
||||
protected.Post("/vcard-api/usuarios/:id/activar-membresia", controllers.VcardApiActivarMembresia)
|
||||
protected.Post("/vcard-api/usuarios/:id/desactivar-membresia", controllers.VcardApiDesactivarMembresia)
|
||||
protected.Post("/vcard-api/usuarios/:id/cambiar-plan", controllers.VcardApiCambiarPlan)
|
||||
// VCards
|
||||
protected.Get("/vcard-api/vcards", controllers.VcardApiVcards)
|
||||
protected.Get("/vcard-api/vcards/:id", controllers.VcardApiVcard)
|
||||
protected.Put("/vcard-api/vcards/:id", controllers.VcardApiVcardUpdate)
|
||||
// Resto (solo GET)
|
||||
protected.Get("/vcard-api/planes", controllers.VcardApiPlanes)
|
||||
protected.Get("/vcard-api/planes/:id", controllers.VcardApiPlan)
|
||||
protected.Put("/vcard-api/planes/:id", controllers.VcardApiPlanUpdate)
|
||||
protected.Get("/vcard-api/pagos", controllers.VcardApiPagos)
|
||||
protected.Get("/vcard-api/transacciones", controllers.VcardApiTransacciones)
|
||||
protected.Get("/vcard-api/logs", controllers.VcardApiLogs)
|
||||
protected.Get("/vcard-api/miniwebs", controllers.VcardApiMiniwebs)
|
||||
protected.Get("/vcard-api/miniwebs/:id", controllers.VcardApiMiniweb)
|
||||
|
||||
// ─── Telegram ─────────────────────────────────────────────────────────────
|
||||
protected.Get("/telegram", middlewares.MenuMiddleware, controllers.TelegramIndex)
|
||||
protected.Get("/loadtelegram", controllers.GetTelegramConfigs)
|
||||
@@ -305,6 +346,9 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
|
||||
protected.Get("/notif-config/data", controllers.GetNotifConfigs)
|
||||
protected.Post("/notif-config", controllers.SaveNotifConfig)
|
||||
// Umbrales de alerta de servidores
|
||||
protected.Get("/servidor-alerta-config", controllers.GetServidorAlertaConfig)
|
||||
protected.Post("/servidor-alerta-config", controllers.SaveServidorAlertaConfig)
|
||||
|
||||
// Partner Recursos (documentación y comunicados para partners)
|
||||
protected.Get("/partner-recursos", middlewares.MenuMiddleware, controllers.PartnerRecursosIndex)
|
||||
|
||||
Reference in New Issue
Block a user