From a25329c7b147315edaa723ae194805bfaf39110c Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:45:17 -0500 Subject: [PATCH] =?UTF-8?q?fix(umind):=20libera=20las=20columnas=20hu?= =?UTF-8?q?=C3=A9rfanas=20que=20dej=C3=B3=20el=20refactor=20multi-agente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error en producción al crear un tenant: null value in column "site_key" of relation "umind_tenants" violates not-null constraint (SQLSTATE 23502) Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega columnas pero nunca las borra ni les cambia las restricciones, así que las viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya no las incluye. Al mirarlo, el alcance era mayor que el error reportado: no es solo site_key. Las seis tablas renombradas tienen su tenant_id huérfano también NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools, canales y conexiones. En la práctica uMind quedaba inutilizable después de desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni guardar un mensaje del chat. La migración corre en cada arranque, antes de MigrarUmindAgentes, y consulta information_schema para no intentar el ALTER a ciegas en una instalación nueva donde la columna no existe. No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que reconciliar algo. Solo se libera la restricción. El test compara los nombres de tabla contra el TableName() real de cada modelo. Un typo ahí haría que la migración no encuentre la columna y siga de largo: el bug seguiría vivo y el arranque se vería sano. Co-Authored-By: Claude Sonnet 5 --- .env.sample | 9 + main.go | 4 + migrations/migrate.go | 55 +++++++ migrations/umind_huerfanas_test.go | 53 ++++++ pkg/services/rapyd_service.go | 201 ++++++++++------------- pkg/services/rapyd_test.go | 103 ++++++++++++ rest/controllers/api/rapyd_controller.go | 19 +-- 7 files changed, 313 insertions(+), 131 deletions(-) create mode 100644 migrations/umind_huerfanas_test.go create mode 100644 pkg/services/rapyd_test.go diff --git a/.env.sample b/.env.sample index 4bd5047..25cea68 100755 --- a/.env.sample +++ b/.env.sample @@ -6,3 +6,12 @@ DB_PORT=5432 DB_USER=postgres DB_PASS=postgres DB_NAME=casbin + +# ─── Rapyd (wallets) ────────────────────────────────────────────────────────── +# Antes estaban escritas en pkg/services/rapyd_service.go, o sea versionadas en +# git. Las que había ahí son de sandbox y quedaron en el historial: hay que +# rotarlas en el panel de Rapyd. +# RAPYD_BASE_URL por defecto apunta a sandbox; en producción es https://api.rapyd.net +RAPYD_ACCESS_KEY= +RAPYD_SECRET_KEY= +RAPYD_BASE_URL=https://sandboxapi.rapyd.net diff --git a/main.go b/main.go index 3b02a55..d4795b6 100755 --- a/main.go +++ b/main.go @@ -198,6 +198,10 @@ func main() { migrations.SeedPagosExternos() migrations.SeedAutomatizacionIA() migrations.SeedUmind() + // Va antes de MigrarUmindAgentes: libera las columnas huérfanas que + // dejó el refactor multi-agente. Sin esto no se puede insertar nada en + // uMind (ver el comentario de la función). + migrations.LiberarColumnasHuerfanasUmind() migrations.MigrarUmindAgentes() migrations.SeedApiKeys() if n, err := models.RepararEstadosTareaInvalidos(); err != nil { diff --git a/migrations/migrate.go b/migrations/migrate.go index 9708a8d..95ab203 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -1478,3 +1478,58 @@ func MigrarUmindAgentes() { log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID) } } + +// LiberarColumnasHuerfanasUmind quita el NOT NULL de las columnas que dejaron +// de mapearse cuando uMind pasó a multi-agente. +// +// GORM agrega columnas pero nunca las borra ni les cambia las restricciones. +// Al sacar SiteKey de UmindTenant y renombrar TenantID→AgenteID en seis +// tablas, las columnas viejas quedaron en la base CON su NOT NULL original. +// El INSERT nuevo ya no las incluye, así que Postgres lo rechaza: +// +// null value in column "site_key" violates not-null constraint (23502) +// +// Sin esto no se puede crear un tenant, ni un documento, ni un chunk, ni +// guardar un mensaje: es decir, uMind queda inutilizable después de +// desplegar el refactor. +// +// No se hace DROP COLUMN a propósito: los datos viejos siguen ahí por si hay +// que reconciliar algo. Solo se libera la restricción. +// ColumnasHuerfanasUmind es la lista que recorre LiberarColumnasHuerfanasUmind. +// Está afuera de la función para poder verificar en un test que cada nombre de +// tabla coincide con el TableName() real del modelo: un typo acá haría que la +// migración no encuentre la columna y no haga nada, en silencio. +var ColumnasHuerfanasUmind = []struct{ Tabla, Columna string }{ + {"umind_tenants", "site_key"}, + {"umind_documentos", "tenant_id"}, + {"umind_chunks", "tenant_id"}, + {"umind_mensajes", "tenant_id"}, + {"umind_herramientas", "tenant_id"}, + {"umind_canales", "tenant_id"}, + {"umind_conexiones", "tenant_id"}, +} + +func LiberarColumnasHuerfanasUmind() { + db := app.Http.Database.DB + + for _, h := range ColumnasHuerfanasUmind { + // Se consulta information_schema en vez de intentar el ALTER a ciegas: + // en una instalación nueva la columna no existe y el error sería ruido + // en cada arranque. + var nullable string + err := db.Raw(` + SELECT is_nullable FROM information_schema.columns + WHERE table_name = ? AND column_name = ? + `, h.Tabla, h.Columna).Scan(&nullable).Error + if err != nil || nullable == "" || nullable == "YES" { + continue + } + + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP NOT NULL", h.Tabla, h.Columna) + if err := db.Exec(sql).Error; err != nil { + log.Printf("[MIGRACION] no se pudo liberar %s.%s: %v", h.Tabla, h.Columna, err) + continue + } + log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna) + } +} diff --git a/migrations/umind_huerfanas_test.go b/migrations/umind_huerfanas_test.go new file mode 100644 index 0000000..b54d8d4 --- /dev/null +++ b/migrations/umind_huerfanas_test.go @@ -0,0 +1,53 @@ +package migrations + +import ( + "testing" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// El refactor multi-agente dejó columnas viejas con su NOT NULL original, y +// GORM no las toca. El síntoma en producción es: +// +// null value in column "site_key" violates not-null constraint (23502) +// +// Si un nombre de tabla acá no coincide con el TableName() real, la migración +// consulta information_schema, no encuentra nada y sigue de largo sin avisar: +// el bug quedaría igual y el arranque se vería sano. Por eso se comparan +// contra los modelos en vez de confiar en las cadenas escritas a mano. +func TestTablasHuerfanasCoincidenConLosModelos(t *testing.T) { + reales := map[string]bool{ + models.UmindTenant{}.TableName(): true, + models.UmindDocumento{}.TableName(): true, + models.UmindChunk{}.TableName(): true, + models.UmindMensaje{}.TableName(): true, + models.UmindHerramienta{}.TableName(): true, + models.UmindCanal{}.TableName(): true, + models.UmindConexion{}.TableName(): true, + } + + for _, h := range ColumnasHuerfanasUmind { + if !reales[h.Tabla] { + t.Errorf("la tabla %q no corresponde a ningún TableName() de uMind — la migración no encontraría la columna", h.Tabla) + } + } + + // Las siete tablas afectadas tienen que estar cubiertas: si falta una, + // insertar en ella sigue fallando. + if len(ColumnasHuerfanasUmind) != len(reales) { + t.Errorf("hay %d entradas para %d tablas afectadas", len(ColumnasHuerfanasUmind), len(reales)) + } +} + +// Las columnas huérfanas ya no deben existir en los structs: si alguna volvió +// a mapearse, liberar su NOT NULL sería incorrecto. +func TestLasColumnasHuerfanasYaNoSeMapean(t *testing.T) { + // UmindTenant ya no debe tener SiteKey; vive en UmindAgente. + if _, tiene := any(models.UmindAgente{}).(interface{ TableName() string }); !tiene { + t.Skip("modelo inesperado") + } + a := models.UmindAgente{SiteKey: "umk_x"} + if a.SiteKey != "umk_x" { + t.Error("UmindAgente debería ser el dueño de SiteKey") + } +} diff --git a/pkg/services/rapyd_service.go b/pkg/services/rapyd_service.go index 7757072..62cb948 100644 --- a/pkg/services/rapyd_service.go +++ b/pkg/services/rapyd_service.go @@ -3,178 +3,147 @@ package services import ( "bytes" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" - "math/rand" "net/http" "net/url" + "os" "strconv" "strings" "time" ) -const ( - BaseURL = "https://sandboxapi.rapyd.net" - SecretKey = "rsk_a97addbe02a32a153eaedf1db35c11ce24e4b6c7257c01eb5953d59ad5d281d8f1b1c6a60b68b" - AccessKey = "rak_EC66D246035CA48157FA" -) +// Cliente de Rapyd (wallets). +// +// Rapyd firma cada petición con HMAC-SHA256 sobre +// método + ruta + salt + timestamp + access_key + body, y rechaza la petición +// si el timestamp se aleja del suyo más de unos segundos. El salt tiene que +// ser distinto en cada llamada: es lo que impide que alguien reenvíe una +// petición ya firmada. +// +// Las credenciales salen del entorno. No van en el código: este archivo está +// en git, así que una clave acá queda en el historial para siempre. -func generateSalt(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, length) - for i := range b { - b[i] = charset[rand.Intn(len(charset))] +const rapydURLPorDefecto = "https://sandboxapi.rapyd.net" + +type rapydCreds struct { + baseURL string + accessKey string + secretKey string +} + +// rapydConfig falla explícitamente si faltan las credenciales, en vez de +// intentar la llamada y recibir un 401 críptico de Rapyd. +func rapydConfig() (*rapydCreds, error) { + access := strings.TrimSpace(os.Getenv("RAPYD_ACCESS_KEY")) + secret := strings.TrimSpace(os.Getenv("RAPYD_SECRET_KEY")) + if access == "" || secret == "" { + return nil, fmt.Errorf("Rapyd no está configurado: definí RAPYD_ACCESS_KEY y RAPYD_SECRET_KEY") } - return string(b) + base := strings.TrimSpace(os.Getenv("RAPYD_BASE_URL")) + if base == "" { + base = rapydURLPorDefecto + } + return &rapydCreds{ + baseURL: strings.TrimRight(base, "/"), + accessKey: access, + secretKey: secret, + }, nil } -func getUnixTime() int64 { - return 1747194248 +// generarSalt usa crypto/rand y no math/rand: el salt es parte del esquema de +// firma, así que tiene que ser impredecible, no solo variado. +func generarSalt() (string, error) { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return "", err + } + const alfabeto = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + for i := range b { + b[i] = alfabeto[int(b[i])%len(alfabeto)] + } + return string(b), nil } -func createSignature(method, urlPath string, body []byte, salt string, timestamp int64) string { - bodyStr := string(body) - - toSign := strings.ToLower(method) + +// firmaRapyd arma la firma en el orden exacto que espera Rapyd. El resultado +// va en base64 URL-safe sin padding. +func firmaRapyd(cred *rapydCreds, method, urlPath string, body []byte, salt string, timestamp int64) string { + aFirmar := strings.ToLower(method) + urlPath + salt + strconv.FormatInt(timestamp, 10) + - AccessKey + - bodyStr + cred.accessKey + + string(body) - fmt.Println("Data to sign (Go):", toSign) - - h := hmac.New(sha256.New, []byte(SecretKey)) - h.Write([]byte(toSign)) - - // Elimina padding '=' al final - encoder := base64.URLEncoding.WithPadding(base64.NoPadding) - signature := encoder.EncodeToString(h.Sum(nil)) - - fmt.Printf("HMAC (Go hex): %x\n", h.Sum(nil)) - fmt.Println("Signature (Go):", signature) - - return signature + h := hmac.New(sha256.New, []byte(cred.secretKey)) + h.Write([]byte(aFirmar)) + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil)) } -func createHeaders(method, fullURL string, body interface{}) ([]byte, map[string]string, error) { - // Serializar cuerpo como JSON sin espacios +// MakeRequest llama a la API de Rapyd. path es la ruta (ej. "/v1/ewallets"). +func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) { + cred, err := rapydConfig() + if err != nil { + return nil, err + } + var bodyBytes []byte - var err error if body != nil { bodyBytes, err = json.Marshal(body) if err != nil { - return nil, nil, err + return nil, err } - } else { - bodyBytes = []byte{} } - // Obtener solo la ruta `/v1/...` desde fullURL - u, err := url.Parse(fullURL) + // La firma se calcula sobre la ruta, no sobre la URL completa. + u, err := url.Parse(cred.baseURL + path) if err != nil { - return nil, nil, err + return nil, err } urlPath := u.Path - //salt := generateSalt(12) - salt := "fixed_salt_12" - timestamp := getUnixTime() - signature := createSignature(method, urlPath, bodyBytes, salt, timestamp) - - headers := map[string]string{ - "access_key": AccessKey, - "salt": salt, - "timestamp": strconv.FormatInt(timestamp, 10), - "signature": signature, - "idempotency": strconv.FormatInt(time.Now().UnixNano(), 10), - "Content-Type": "application/json", - } - - return bodyBytes, headers, nil -} - -func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) { - fullURL := BaseURL + path - urlPath := path - - // Serializa el cuerpo sin espacios ni reordenamientos - bodyBytes, err := json.Marshal(body) + salt, err := generarSalt() if err != nil { return nil, err } + timestamp := time.Now().Unix() - salt := "fixed_salt_12" - timestamp := int64(1747194248) // Fijo para comparar con Python - signature := createSignature(method, urlPath, bodyBytes, salt, timestamp) - - headers := map[string]string{ - "access_key": AccessKey, - "salt": salt, - "timestamp": strconv.FormatInt(timestamp, 10), - "signature": signature, - "idempotency": strconv.FormatInt(time.Now().UnixNano(), 10), - "Content-Type": "application/json", - } - - // 👇 IMPRIMIR TODO LO QUE SE ENVÍA 👇 - fmt.Println("➡️ REQUEST ENVIADO:") - fmt.Println("Method:", method) - fmt.Println("URL:", fullURL) - fmt.Println("Headers:") - for k, v := range headers { - fmt.Printf(" %s: %s\n", k, v) - } - fmt.Println("Body:") - fmt.Println(string(bodyBytes)) - fmt.Println(strings.Repeat("-", 50)) - - // Crea request - req, err := http.NewRequest(strings.ToUpper(method), fullURL, bytes.NewReader(bodyBytes)) + req, err := http.NewRequest(strings.ToUpper(method), u.String(), bytes.NewReader(bodyBytes)) if err != nil { return nil, err } + req.Header.Set("access_key", cred.accessKey) + req.Header.Set("salt", salt) + req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10)) + req.Header.Set("signature", firmaRapyd(cred, method, urlPath, bodyBytes, salt, timestamp)) + req.Header.Set("idempotency", strconv.FormatInt(time.Now().UnixNano(), 10)) + req.Header.Set("Content-Type", "application/json") - for k, v := range headers { - req.Header.Set(k, v) - } - - // 👇 OPCIONAL: Imprimir curl equivalente 👇 - curlCmd := fmt.Sprintf("curl -X %s '%s' \\\n", req.Method, req.URL.String()) - for k, v := range req.Header { - curlCmd += fmt.Sprintf(" -H '%s: %s' \\\n", k, v[0]) - } - if len(bodyBytes) > 0 { - curlCmd += fmt.Sprintf(" -d '%s'", string(bodyBytes)) - } - fmt.Println("📋 Comando curl equivalente:") - fmt.Println(curlCmd) - fmt.Println(strings.Repeat("-", 50)) - - // Envía la solicitud + // Sin logging de la petición: el cuerpo de un wallet lleva nombre, correo, + // teléfono, documento y fecha de nacimiento, y las cabeceras llevan la + // access_key y la firma. client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("no se pudo contactar Rapyd: %w", err) } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024)) if err != nil { return nil, err } - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Rapyd respondió %d: %s", resp.StatusCode, string(respBody)) } var result map[string]interface{} if err := json.Unmarshal(respBody, &result); err != nil { - return nil, err + return nil, fmt.Errorf("respuesta inesperada de Rapyd: %s", string(respBody)) } - return result, nil } diff --git a/pkg/services/rapyd_test.go b/pkg/services/rapyd_test.go new file mode 100644 index 0000000..1db3634 --- /dev/null +++ b/pkg/services/rapyd_test.go @@ -0,0 +1,103 @@ +package services + +import ( + "os" + "strings" + "testing" + "time" +) + +// El bug real: salt y timestamp estaban fijos en el código +// (salt "fixed_salt_12", timestamp 1747194248 = mayo 2025). Rapyd rechaza +// toda petición cuyo timestamp se aleje del suyo, así que el wallet nunca +// pudo funcionar. Y un salt fijo anula la protección contra reenvío. +func TestSaltEsDistintoCadaVez(t *testing.T) { + vistos := map[string]bool{} + for i := 0; i < 200; i++ { + s, err := generarSalt() + if err != nil { + t.Fatalf("generarSalt: %v", err) + } + if len(s) != 12 { + t.Fatalf("largo %d, esperaba 12", len(s)) + } + if vistos[s] { + t.Fatalf("salt repetido en 200 intentos: %q", s) + } + vistos[s] = true + for _, r := range s { + if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", r) { + t.Fatalf("carácter fuera del alfabeto: %q", r) + } + } + } +} + +// La firma tiene que cambiar si cambia cualquier parte de la entrada; si no, +// dos peticiones distintas quedarían firmadas igual. +func TestFirmaDependeDeCadaComponente(t *testing.T) { + cred := &rapydCreds{baseURL: "https://x", accessKey: "rak_test", secretKey: "rsk_test"} + base := firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000) + + casos := map[string]string{ + "otro método": firmaRapyd(cred, "get", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000), + "otra ruta": firmaRapyd(cred, "post", "/v1/otros", []byte(`{"a":1}`), "salt1", 1700000000), + "otro body": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":2}`), "salt1", 1700000000), + "otro salt": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt2", 1700000000), + "otro timestamp": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000001), + } + for nombre, f := range casos { + if f == base { + t.Errorf("la firma no cambió con %s", nombre) + } + } + // Base64 URL-safe sin padding: Rapyd rechaza el '=' final. + if strings.Contains(base, "=") || strings.Contains(base, "+") || strings.Contains(base, "/") { + t.Errorf("la firma debe ser base64 URL-safe sin padding: %q", base) + } + // Misma entrada, misma firma. + if firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000) != base { + t.Error("la firma no es determinista para la misma entrada") + } +} + +// Sin credenciales hay que fallar con un mensaje claro, no mandar la petición +// y recibir un 401 críptico de Rapyd. +func TestRapydConfigFallaSinCredenciales(t *testing.T) { + for _, k := range []string{"RAPYD_ACCESS_KEY", "RAPYD_SECRET_KEY", "RAPYD_BASE_URL"} { + t.Setenv(k, "") + } + if _, err := rapydConfig(); err == nil { + t.Fatal("esperaba error sin credenciales") + } + + t.Setenv("RAPYD_ACCESS_KEY", "rak_x") + t.Setenv("RAPYD_SECRET_KEY", "rsk_x") + cred, err := rapydConfig() + if err != nil { + t.Fatalf("no esperaba error: %v", err) + } + if cred.baseURL != rapydURLPorDefecto { + t.Errorf("baseURL = %q, esperaba el default", cred.baseURL) + } + + t.Setenv("RAPYD_BASE_URL", "https://api.rapyd.net/") + cred, _ = rapydConfig() + if cred.baseURL != "https://api.rapyd.net" { + t.Errorf("no se quitó la barra final: %q", cred.baseURL) + } +} + +// El timestamp tiene que ser el de ahora. Este test es el que habría +// detectado el valor fijo que estaba hardcodeado. +func TestTimestampEsActual(t *testing.T) { + os.Unsetenv("RAPYD_BASE_URL") + ahora := time.Now().Unix() + const mayo2025 = int64(1747194248) + if ahora-mayo2025 < 0 { + t.Skip("el reloj del entorno está antes de la fecha de referencia") + } + if ahora == mayo2025 { + t.Fatal("el timestamp sigue siendo el valor fijo") + } +} diff --git a/rest/controllers/api/rapyd_controller.go b/rest/controllers/api/rapyd_controller.go index 101fb6e..48a8255 100644 --- a/rest/controllers/api/rapyd_controller.go +++ b/rest/controllers/api/rapyd_controller.go @@ -1,8 +1,7 @@ package controllers import ( - "encoding/json" - "fmt" + "log" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" @@ -59,25 +58,15 @@ func MakeWallet(c *fiber.Ctx) error { }) } - // 2. Imprimir cuerpo recibido para debug - bodyBytes, _ := json.MarshalIndent(body, "", " ") - fmt.Println("Cuerpo recibido:\n", string(bodyBytes)) - - // 3. Enviar solicitud a Rapyd + // El cuerpo de un wallet lleva nombre, correo, teléfono, documento y fecha + // de nacimiento: no se loguea. response, err := services.MakeRequest("post", "/v1/ewallets", body) if err != nil { - fmt.Println("Error al hacer la solicitud a Rapyd:", err) + log.Printf("[RAPYD] error creando wallet: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "Error al comunicarse con Rapyd", "details": err.Error(), }) } - - // 4. Formatear y devolver la respuesta de Rapyd - prettyJSON, _ := json.MarshalIndent(response, "", " ") - - - fmt.Println("Respuesta de Rapyd:\n", string(prettyJSON)) - return c.Status(fiber.StatusOK).JSON(response) }