This commit is contained in:
Lizandro Guarnizo
2026-05-26 20:54:10 -05:00
parent 39f4641540
commit 7a374340c1
5 changed files with 541 additions and 2 deletions
+10
View File
@@ -74,6 +74,16 @@ func DeleteOssApi(OssApi *OssApi) error {
return nil
}
// GetOssApiByID obtiene un registro por ID
func GetOssApiByID(id uint) (*OssApi, error) {
var item OssApi
err := app.Http.Database.DB.First(&item, id).Error
if err != nil {
return nil, err
}
return &item, nil
}
// GetLastActiveOssApi obtiene el último registro activo
func GetLastActiveOssApi() (*OssApi, error) {
var ossConfig OssApi
+352
View File
@@ -0,0 +1,352 @@
<!-- Vista: Alibaba Cloud OSS — Gestión de configuraciones -->
<div x-data="ossApiApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
<!-- Overlay de carga -->
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold">Alibaba Cloud OSS</h1>
<p class="text-xs text-slate-500 mt-0.5">Gestiona las configuraciones de acceso a Alibaba Cloud Object Storage Service (OSS). La configuración activa es usada por todos los servicios de almacenamiento.</p>
</div>
<button @click="openAdd()"
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
Nueva configuración
</button>
</div>
<!-- Alerta de error -->
<div x-show="errorMsg" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
<!-- Barra de búsqueda -->
<div class="flex flex-col sm:flex-row gap-3 mb-5">
<input x-model="search" @keyup.enter="load()" type="text" placeholder="Buscar por nombre..."
class="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<button @click="load()" :disabled="loading"
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm hover:bg-gray-50 transition disabled:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="loading && 'animate-spin'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
</svg>
<span x-text="loading ? 'Cargando...' : 'Buscar'"></span>
</button>
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Endpoint</th>
<th class="py-2 px-3">Bucket</th>
<th class="py-2 px-3">Región</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
<template x-if="items.length === 0 && !loading">
<tr>
<td colspan="6" class="py-8 text-center text-gray-400 text-sm">
Sin configuraciones. Haz clic en "Nueva configuración" para agregar una.
</td>
</tr>
</template>
<template x-for="item in items" :key="item.ID">
<tr class="border-b border-gray-100 hover:bg-gray-50 transition-colors">
<td class="py-2 px-3 font-medium text-gray-800" x-text="item.name"></td>
<td class="py-2 px-3 font-mono text-xs text-blue-700 break-all" x-text="item.endpoint"></td>
<td class="py-2 px-3 text-xs text-gray-600" x-text="item.bucket_name"></td>
<td class="py-2 px-3 text-xs text-gray-500" x-text="item.region || '—'"></td>
<td class="py-2 px-3">
<span x-text="item.is_active ? 'Activo' : 'Inactivo'"
:class="item.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="text-xs px-2 py-0.5 rounded-full font-medium"></span>
</td>
<td class="py-2 px-3">
<div class="flex items-center gap-2 justify-end">
<button @click="openEdit(item)" title="Editar"
class="text-blue-500 hover:text-blue-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="confirmDelete(item.ID)" title="Eliminar"
class="text-red-400 hover:text-red-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="flex items-center justify-between mt-4 text-sm text-gray-500" x-show="totalPages > 1">
<span x-text="`Página ${page} de ${totalPages} — ${total} registro(s)`"></span>
<div class="flex gap-2">
<button @click="prevPage()" :disabled="page <= 1"
class="px-3 py-1 border rounded text-xs disabled:opacity-40 hover:bg-gray-50">Anterior</button>
<button @click="nextPage()" :disabled="page >= totalPages"
class="px-3 py-1 border rounded text-xs disabled:opacity-40 hover:bg-gray-50">Siguiente</button>
</div>
</div>
</div>
<!-- ─── Modal crear / editar ─────────────────────────────────────────────── -->
<div x-show="showModal" class="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg" @click.stop>
<div class="flex items-center justify-between px-6 py-4 border-b">
<h2 class="text-lg font-semibold" x-text="editItem ? 'Editar configuración OSS' : 'Nueva configuración OSS'"></h2>
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form @submit.prevent="save()" class="px-6 py-4 space-y-4">
<!-- Nombre -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Nombre / Alias <span class="text-red-500">*</span></label>
<input x-model="form.name" type="text" placeholder="ej. OSS Producción"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" required />
</div>
<!-- Endpoint -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Endpoint <span class="text-red-500">*</span></label>
<input x-model="form.endpoint" type="text" placeholder="ej. https://oss-cn-hangzhou.aliyuncs.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" required />
</div>
<!-- Access Key ID -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">
Access Key ID
<span x-show="!editItem" class="text-red-500">*</span>
<span x-show="editItem" class="text-gray-400 font-normal">(dejar vacío para mantener)</span>
</label>
<input x-model="form.access_key_id" type="text" placeholder="LTAI5t..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
:required="!editItem" />
</div>
<!-- Access Key Secret -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">
Access Key Secret
<span x-show="!editItem" class="text-red-500">*</span>
<span x-show="editItem" class="text-gray-400 font-normal">(dejar vacío para mantener)</span>
</label>
<input x-model="form.access_key_secret" type="password" placeholder="••••••••••••"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
:required="!editItem" />
</div>
<!-- Bucket + Región -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Bucket <span class="text-red-500">*</span></label>
<input x-model="form.bucket_name" type="text" placeholder="mi-bucket"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Región</label>
<input x-model="form.region" type="text" placeholder="ej. cn-hangzhou"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Notas</label>
<textarea x-model="form.notes" rows="2" placeholder="Observaciones opcionales..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f] resize-none"></textarea>
</div>
<!-- Activo -->
<div class="flex items-center gap-3">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="form.is_active" class="sr-only peer" />
<div class="w-10 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-5 peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[#8eb02f]"></div>
</label>
<span class="text-sm text-gray-600">Configuración activa</span>
</div>
<!-- Error modal -->
<p x-show="modalError" class="text-xs text-red-600" x-text="modalError"></p>
<!-- Botones -->
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="closeModal()"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
<button type="submit" :disabled="saving"
class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="saving ? 'Guardando...' : 'Guardar'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- ─── Modal confirmación eliminar ─────────────────────────────────────── -->
<div x-show="deleteId !== null" class="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto text-red-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/>
</svg>
<h3 class="text-base font-semibold text-gray-800 mb-1">¿Eliminar configuración?</h3>
<p class="text-sm text-gray-500 mb-5">Esta acción no se puede deshacer.</p>
<div class="flex gap-3 justify-center">
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="doDelete()" :disabled="saving"
class="px-4 py-2 text-sm text-white bg-red-500 rounded-lg hover:bg-red-600 transition disabled:opacity-50">
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
</button>
</div>
</div>
</div>
</div>
<script>
function ossApiApp() {
return {
items: [],
total: 0,
totalPages: 1,
page: 1,
search: '',
loading: false,
errorMsg: '',
showModal: false,
editItem: null,
saving: false,
modalError: '',
deleteId: null,
form: {
name: '',
endpoint: '',
access_key_id: '',
access_key_secret: '',
bucket_name: '',
region: '',
is_active: true,
notes: '',
},
async init() {
await this.load();
},
async load() {
this.loading = true;
this.errorMsg = '';
try {
const params = new URLSearchParams({ page: this.page, search: this.search });
const res = await fetch(`/app/loadossapi?${params}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al cargar');
this.items = data.items || [];
this.total = data.total || 0;
this.totalPages = data.totalPages || 1;
} catch (e) {
this.errorMsg = e.message;
} finally {
this.loading = false;
}
},
openAdd() {
this.editItem = null;
this.form = { name: '', endpoint: '', access_key_id: '', access_key_secret: '', bucket_name: '', region: '', is_active: true, notes: '' };
this.modalError = '';
this.showModal = true;
},
openEdit(item) {
this.editItem = item;
this.form = {
name: item.name || '',
endpoint: item.endpoint || '',
access_key_id: '',
access_key_secret: '',
bucket_name: item.bucket_name || '',
region: item.region || '',
is_active: item.is_active,
notes: item.notes || '',
};
this.modalError = '';
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.editItem = null;
this.modalError = '';
},
async save() {
this.saving = true;
this.modalError = '';
try {
const url = this.editItem ? `/app/oss-api/${this.editItem.ID}` : '/app/oss-api';
const method = this.editItem ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al guardar');
this.closeModal();
await this.load();
} catch (e) {
this.modalError = e.message;
} finally {
this.saving = false;
}
},
confirmDelete(id) {
this.deleteId = id;
},
async doDelete() {
if (!this.deleteId) return;
this.saving = true;
try {
const res = await fetch(`/app/oss-api/${this.deleteId}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al eliminar');
this.deleteId = null;
await this.load();
} catch (e) {
this.errorMsg = e.message;
this.deleteId = null;
} finally {
this.saving = false;
}
},
prevPage() {
if (this.page > 1) { this.page--; this.load(); }
},
nextPage() {
if (this.page < this.totalPages) { this.page++; this.load(); }
},
};
}
</script>
+159
View File
@@ -0,0 +1,159 @@
package controllers
import (
"math"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// OssApiIndex renderiza la vista del panel de gestión de Alibaba Cloud OSS.
func OssApiIndex(c *fiber.Ctx) error {
data := fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
}
return c.Render("oss_api", data, "layouts/main")
}
// GetOssApiConfigs devuelve la lista paginada de configuraciones en JSON.
func GetOssApiConfigs(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
search := c.Query("search", "")
items, total, err := models.GetAllOssApi(limit, offset, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
"limit": limit,
})
}
// CreateOssApiConfig crea una nueva configuración de Alibaba Cloud OSS.
func CreateOssApiConfig(c *fiber.Ctx) error {
type Req struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
BucketName string `json:"bucket_name"`
Region string `json:"region"`
IsActive bool `json:"is_active"`
Notes string `json:"notes"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Name) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name es requerido"})
}
if strings.TrimSpace(req.Endpoint) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "endpoint es requerido"})
}
if strings.TrimSpace(req.AccessKeyID) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "access_key_id es requerido"})
}
if strings.TrimSpace(req.AccessKeySecret) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "access_key_secret es requerido"})
}
if strings.TrimSpace(req.BucketName) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bucket_name es requerido"})
}
item := models.OssApi{
Name: strings.TrimSpace(req.Name),
Endpoint: strings.TrimSpace(req.Endpoint),
AccessKeyID: strings.TrimSpace(req.AccessKeyID),
AccessKeySecret: strings.TrimSpace(req.AccessKeySecret),
BucketName: strings.TrimSpace(req.BucketName),
Region: strings.TrimSpace(req.Region),
IsActive: req.IsActive,
Notes: strings.TrimSpace(req.Notes),
}
if err := models.CreateOssApi(&item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "item": item})
}
// UpdateOssApiConfig actualiza una configuración existente de Alibaba Cloud OSS.
func UpdateOssApiConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
existing, err := models.GetOssApiByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "configuración no encontrada"})
}
type Req struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
BucketName string `json:"bucket_name"`
Region string `json:"region"`
IsActive bool `json:"is_active"`
Notes string `json:"notes"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Name) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name es requerido"})
}
existing.Name = strings.TrimSpace(req.Name)
existing.Endpoint = strings.TrimSpace(req.Endpoint)
existing.BucketName = strings.TrimSpace(req.BucketName)
existing.Region = strings.TrimSpace(req.Region)
existing.IsActive = req.IsActive
existing.Notes = strings.TrimSpace(req.Notes)
// Solo actualizar secrets si se envían valores no vacíos
if strings.TrimSpace(req.AccessKeyID) != "" {
existing.AccessKeyID = strings.TrimSpace(req.AccessKeyID)
}
if strings.TrimSpace(req.AccessKeySecret) != "" {
existing.AccessKeySecret = strings.TrimSpace(req.AccessKeySecret)
}
if err := models.UpdateOssApi(existing); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "item": existing})
}
// DeleteOssApiConfig elimina una configuración de Alibaba Cloud OSS.
func DeleteOssApiConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
item, err := models.GetOssApiByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "configuración no encontrada"})
}
if err := models.DeleteOssApi(item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
+7
View File
@@ -271,6 +271,13 @@ func UserRoutes(app fiber.Router) {
protected.Get("/saas-api/logs", middlewares.MenuMiddleware, controllers.SaasDispatchLogIndex)
protected.Get("/loadsaasdispatchlogs", controllers.GetSaasDispatchLogs)
// ─── Alibaba Cloud OSS API ────────────────────────────────────────────────
protected.Get("/oss-api", middlewares.MenuMiddleware, controllers.OssApiIndex)
protected.Get("/loadossapi", controllers.GetOssApiConfigs)
protected.Post("/oss-api", controllers.CreateOssApiConfig)
protected.Put("/oss-api/:id", controllers.UpdateOssApiConfig)
protected.Delete("/oss-api/:id", controllers.DeleteOssApiConfig)
// ─── VCard API (integración Admin Laravel) ────────────────────────────────
protected.Get("/vcard-api", middlewares.MenuMiddleware, controllers.VcardApiIndex)
protected.Post("/vcard-api/login", controllers.VcardApiLogin)
+13 -2
View File
@@ -46,12 +46,23 @@ WHERE NOT EXISTS (
SELECT 1 FROM submodules WHERE url = '/app/vcard-api' AND deleted_at IS NULL
);
-- 5. Asignar todos los submódulos de Integraciones a TODOS los roles (ignorar duplicados)
-- 5. Insertar submódulo Alibaba Cloud OSS si no existe
INSERT INTO submodules (title, description, url, module_id, modified_at, created_at, updated_at)
SELECT 'Alibaba Cloud OSS',
'Gestión de configuraciones de acceso a Alibaba Cloud Object Storage Service (OSS): credenciales, buckets y regiones',
'/app/oss-api',
(SELECT id FROM modules WHERE title = 'Integraciones' AND deleted_at IS NULL LIMIT 1),
NOW(), NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM submodules WHERE url = '/app/oss-api' AND deleted_at IS NULL
);
-- 6. Asignar todos los submódulos de Integraciones a TODOS los roles (ignorar duplicados)
INSERT INTO roles_submodules (role_id, submodule_id)
SELECT r.id, s.id
FROM roles r
CROSS JOIN submodules s
WHERE s.url IN ('/app/hostinger', '/app/cloudflare', '/app/vcard-api')
WHERE s.url IN ('/app/hostinger', '/app/cloudflare', '/app/vcard-api', '/app/oss-api')
AND r.deleted_at IS NULL
AND s.deleted_at IS NULL
ON CONFLICT DO NOTHING;