UNPKG

@titamedia/delectable-sdk

Version:

SDK oficial para los servicios del chatbot Delectable

1,651 lines (1,409 loc) 54.9 kB
// src/core/logger.js const LOG_CONFIG = { API_CALLS: false, API_ERRORS: true, CHAT_MESSAGES: false, CHAT_ACTIONS: false, USER_DATA: false, USER_AUTH: false, CART_OPERATIONS: false, SHOPPING_LIST: false, DEBUG: false, PERFORMANCE: false, ERRORS: true, WARNINGS: true, }; class Logger { static _log(category, level, message, data = null) { if (!LOG_CONFIG[category]) return; const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const prefix = `[${timestamp}] [@delectable/sdk] [${category}]`; switch (level) { case 'error': console.error(`❌ ${prefix}`, message, data || ''); break; case 'warn': console.warn(`⚠️ ${prefix}`, message, data || ''); break; case 'info': console.info(`ℹ️ ${prefix}`, message, data || ''); break; case 'success': console.log(`✅ ${prefix}`, message, data || ''); break; default: console.log(`🔍 ${prefix}`, message, data || ''); } } // API Logging Methods static apiCall(message, data) { this._log('API_CALLS', 'info', message, data); } static apiError(message, error) { this._log('API_ERRORS', 'error', message, error); } static apiSuccess(message, data) { this._log('API_CALLS', 'success', message, data); } // User Data Logging Methods static userData(message, data) { this._log('USER_DATA', 'info', message, data); } static userAuth(message, data) { this._log('USER_AUTH', 'info', message, data); } // Shopping Logging Methods static cartOp(message, data) { this._log('CART_OPERATIONS', 'info', message, data); } static shoppingList(message, data) { this._log('SHOPPING_LIST', 'info', message, data); } // General Logging Methods static debug(message, data) { this._log('DEBUG', 'debug', message, data); } static error(message, error) { this._log('ERRORS', 'error', message, error); } static warn(message, data) { this._log('WARNINGS', 'warn', message, data); } // Configuration Methods static enableCategory(category) { if (LOG_CONFIG.hasOwnProperty(category)) { LOG_CONFIG[category] = true; console.log(`✅ [@delectable/sdk] Logger: Enabled category '${category}'`); } else { console.warn(`⚠️ [@delectable/sdk] Logger: Unknown category '${category}'`); } } static disableCategory(category) { if (LOG_CONFIG.hasOwnProperty(category)) { LOG_CONFIG[category] = false; console.log(`❌ [@delectable/sdk] Logger: Disabled category '${category}'`); } else { console.warn(`⚠️ [@delectable/sdk] Logger: Unknown category '${category}'`); } } static showConfig() { console.log('🔧 [@delectable/sdk] Logger Configuration:'); Object.entries(LOG_CONFIG).forEach(([category, enabled]) => { console.log(` ${enabled ? '✅' : '❌'} ${category}`); }); } static enableAll() { Object.keys(LOG_CONFIG).forEach(category => { LOG_CONFIG[category] = true; }); console.log('🔥 [@delectable/sdk] Logger: All categories enabled'); } static disableAll() { Object.keys(LOG_CONFIG).forEach(category => { if (category !== 'ERRORS' && category !== 'WARNINGS') { LOG_CONFIG[category] = false; } }); console.log('🔇 [@delectable/sdk] Logger: All non-critical categories disabled'); } } // src/core/errors.js class DelectableError extends Error { constructor(message, status = null, data = null) { super(message); this.name = 'DelectableError'; this.status = status; this.data = data; } } class NetworkError extends DelectableError { constructor(message) { super(message); this.name = 'NetworkError'; } } class AuthenticationError extends DelectableError { constructor(message) { super(message); this.name = 'AuthenticationError'; this.status = 401; } } class ValidationError extends DelectableError { constructor(message, validationErrors = null) { super(message); this.name = 'ValidationError'; this.status = 422; this.validationErrors = validationErrors; } } class NotFoundError extends DelectableError { constructor(message) { super(message); this.name = 'NotFoundError'; this.status = 404; } } class ServerError extends DelectableError { constructor(message) { super(message); this.name = 'ServerError'; this.status = 500; } } class AuthModule { constructor(http, config) { this.http = http; this.config = config; this.standardPassword = "password"; } /** * Valida si un email es válido * @param {string} email - Email a validar * @returns {boolean} */ isValidEmail(email) { if (!email || typeof email !== "string") return false; // Basic email format check const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; if (!emailRegex.test(email)) return false; // Filtros para evitar emails falsos o corruptos const suspiciousPatterns = [ "vtex.product-list", "overrides.css", ".css", ".js", ".json", ".html", "localhost", "127.0.0.1", "noreply", "no-reply", "donotreply", ]; for (const pattern of suspiciousPatterns) { if (email.toLowerCase().includes(pattern)) { return false; } } return email.length <= 100; } /** * Determina si el data-id es válido para autenticación * @param {string} dataId - Valor del data-id * @returns {boolean} */ isValidDataId(dataId) { return ( dataId && dataId !== "undefined" && dataId !== "null" && dataId.trim() !== "" && this.isValidEmail(dataId) ); } /** * Login anónimo * @returns {Promise<Object>} Datos de autenticación */ async loginAnonymous() { Logger.userAuth("Attempting anonymous login"); try { const response = await this.http.post( this.config.endpoints.LOGIN_ANONYMOUS ); Logger.userAuth("Anonymous login successful", { user_id: response.user_id, session_id: response.session_id, }); return { access_token: response.access_token, token_type: response.token_type, session_id: response.session_id, user_id: response.user_id, // Legacy compatibility token: response.access_token, sessionId: response.session_id, userId: response.user_id, }; } catch (error) { Logger.apiError("Anonymous login failed", error); throw new AuthenticationError("Failed to authenticate anonymously"); } } /** * Login con credenciales * @param {string} username - Nombre de usuario * @param {string} password - Contraseña * @returns {Promise<Object>} Datos de autenticación */ async login(username, password) { if (!username || username === "undefined" || username.trim() === "") { throw new ValidationError("Valid username is required"); } if (!password) { throw new ValidationError("Password is required"); } Logger.userAuth("Attempting user login", { username }); const payload = new URLSearchParams({ grant_type: "password", username: username, password: password, scope: "", client_id: "", client_secret: "", }); try { const response = await this.http.request(this.config.endpoints.LOGIN, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: payload.toString(), }); if (response && response.access_token) { Logger.userAuth("User login successful", { username, session_id: response.session_id, }); return { access_token: response.access_token, token_type: response.token_type, session_id: response.session_id, user_id: username, // Legacy compatibility token: response.access_token, sessionId: response.session_id, userId: username, }; } throw new AuthenticationError(`Login failed for ${username}`); } catch (error) { Logger.apiError("User login failed", error); throw new AuthenticationError( `Login failed for ${username}: ${error.message}` ); } } /** * Autenticación VTEX principal - maneja todos los escenarios * @param {string} dataId - Valor del data-id del componente VTEX * @returns {Promise<Object>} Resultado de autenticación */ async authenticateVtex(dataId) { // Reduced logging for performance try { // Escenario 1: data-id vacío, undefined o inválido → login anónimo if (!this.isValidDataId(dataId)) { // Anonymous login for invalid data-id const anonymousResult = await this.loginAnonymous(); return { success: true, type: "anonymous", authData: anonymousResult, message: "Anonymous authentication successful", }; } // Escenario 2: data-id válido → intentar login con email // Attempting user authentication with valid email try { // Intentar login con password estándar const loginResult = await this.login(dataId, this.standardPassword); // Existing user authenticated return { success: true, type: "existing_user", authData: loginResult, email: dataId, message: "Existing user authentication successful", }; } catch (loginError) { // Si falla el login, verificar si es porque el usuario no existe if ( loginError.message.includes("Incorrect username or password") || loginError.message.includes("Login failed") ) { Logger.userAuth("User not found, attempting to create new user", { email: dataId, }); try { // Crear usuario y luego hacer login await this.createUser(dataId); // Esperar un momento para que se complete la creación await this.delay(2000); // Intentar login nuevamente const newUserLogin = await this.login( dataId, this.standardPassword ); Logger.userAuth("New user created and authenticated successfully", { email: dataId, }); return { success: true, type: "new_user", authData: newUserLogin, email: dataId, message: "New user created and authenticated successfully", }; } catch (createError) { Logger.apiError( "Failed to create user, falling back to anonymous", createError ); // Fallback a login anónimo si falla la creación const anonymousResult = await this.loginAnonymous(); return { success: true, type: "anonymous_fallback", authData: anonymousResult, email: dataId, error: createError.message, message: "User creation failed, using anonymous session", }; } } else { // Error diferente, fallback a anónimo Logger.apiError( "Authentication error, falling back to anonymous", loginError ); const anonymousResult = await this.loginAnonymous(); return { success: true, type: "anonymous_fallback", authData: anonymousResult, email: dataId, error: loginError.message, message: "Authentication failed, using anonymous session", }; } } } catch (error) { Logger.apiError("VTEX authentication failed completely", error); // Último recurso: login anónimo try { const anonymousResult = await this.loginAnonymous(); return { success: true, type: "anonymous_emergency", authData: anonymousResult, error: error.message, message: "Emergency fallback to anonymous session", }; } catch (anonymousError) { Logger.apiError("Even anonymous login failed", anonymousError); throw new AuthenticationError("Complete authentication failure"); } } } /** * Crear usuario con password estándar * @param {string} email - Email del usuario * @returns {Promise<Object>} Resultado de creación */ async createUser(email) { Logger.userAuth("Creating user with standard password", { email }); const emailParts = email.split("@"); const firstName = emailParts[0] || "User"; const userData = { username: email, password: this.standardPassword, user_source: "Delectable", agree_to_license: true, email: email, first_name: firstName, last_name: "", phone: "", created_by: "delectabot", }; try { const response = await this.http.request(this.config.endpoints.USERS, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(userData), }); Logger.userAuth("User created successfully", { email }); return response; } catch (error) { // Si el usuario ya existe, no es un error crítico if (error.message.includes("already exists") || error.status === 409) { Logger.userAuth("User already exists, continuing with login", { email, }); return { message: "User already exists" }; } Logger.apiError("User creation failed", error); throw new AuthenticationError(`Failed to create user: ${error.message}`); } } /** * Limpiar credenciales almacenadas */ clearStoredCredentials() { Logger.userAuth("Clearing stored credentials"); if (typeof window !== "undefined") { // Limpiar localStorage const keysToRemove = [ "userToken", "userId", "sessionId", "authData", "vtex-user-profile", "vtex-session", "vtex_commerce_user", ]; keysToRemove.forEach((key) => { localStorage.removeItem(key); sessionStorage.removeItem(key); }); // Limpiar cookies relacionadas con autenticación document.cookie.split(";").forEach((cookie) => { const eqPos = cookie.indexOf("="); const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); if ( name.includes("auth") || name.includes("token") || name.includes("session") ) { document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; } }); } } /** * Manejar logout o inactividad * @param {string} dataId - Valor actual del data-id * @returns {Promise<Object>} Resultado de la acción */ async handleLogoutOrInactivity(dataId) { Logger.userAuth("Handling logout or inactivity", { dataId: dataId ? "present" : "empty", }); // Limpiar credenciales almacenadas this.clearStoredCredentials(); // Si data-id está vacío, iniciar sesión anónima if (!this.isValidDataId(dataId)) { Logger.userAuth("Data-id empty after logout, starting anonymous session"); const anonymousResult = await this.loginAnonymous(); return { success: true, type: "anonymous_after_logout", authData: anonymousResult, message: "Anonymous session started after logout", }; } // Si data-id tiene valor válido, mantener la autenticación Logger.userAuth( "Data-id still valid after logout, maintaining authentication" ); return { success: true, type: "maintain_auth", message: "Authentication maintained due to valid data-id", }; } /** * Refresh token * @param {string} refreshToken - Token de refresh * @returns {Promise<string>} Nuevo access token */ async refreshToken(refreshToken) { Logger.userAuth("Attempting token refresh"); try { const anonymousAuth = await this.loginAnonymous(); Logger.userAuth("Token refreshed via anonymous login"); return anonymousAuth.access_token; } catch (error) { Logger.apiError("Token refresh failed", error); throw new AuthenticationError("Failed to refresh authentication token"); } } /** * Utilidad para delay * @param {number} ms - Milisegundos a esperar * @returns {Promise} */ delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } } class MealPlansModule { constructor(http, config) { this.http = http; this.config = config; } /** * Obtener planes de comida del usuario * @param {Object} options - Opciones de consulta * @param {string} options.username - Nombre de usuario * @param {number} options.skip - Elementos a omitir * @param {number} options.limit - Límite de elementos * @returns {Promise<Object>} Lista de planes de comida */ async fetchMealPlans({ username, skip = 0, limit = 10 } = {}) { if (!username) { throw new ValidationError('Username is required for fetching meal plans'); } Logger.apiCall('Fetching meal plans', { username, skip, limit }); const url = `${this.config.endpoints.MEAL_PLANS}/?skip=${skip}&limit=${limit}&created_by=${encodeURIComponent(username)}`; try { const response = await this.http.get(url); return { type: 'direct_data', data: { items: response.items || [], total: response.total || 0, page: response.page || 1, size: response.size || limit, pages: response.pages || 1 }, full_response: response }; } catch (error) { throw this._handleMealPlanError(error, 'fetch meal plans'); } } /** * Crear nuevo plan de comida * @param {Object} mealPlanData - Datos del plan de comida * @returns {Promise<Object>} Plan de comida creado */ async createMealPlan(mealPlanData) { Logger.apiCall('Creating meal plan', mealPlanData); try { return await this.http.post(this.config.endpoints.MEAL_PLANS, mealPlanData); } catch (error) { throw this._handleMealPlanError(error, 'create meal plan'); } } /** * Eliminar plan de comida * @param {string} mealPlanId - ID del plan de comida * @returns {Promise<Object>} Resultado de la eliminación */ async deleteMealPlan(mealPlanId) { if (!mealPlanId) { throw new ValidationError('Meal plan ID is required for deletion'); } Logger.apiCall('Deleting meal plan', { mealPlanId }); try { return await this.http.delete(`${this.config.endpoints.MEAL_PLANS}/${mealPlanId}`); } catch (error) { throw this._handleMealPlanError(error, 'delete meal plan'); } } /** * Generar plan de comida basado en preferencias * @param {Object} formData - Datos del formulario * @returns {Promise<Object>} Plan de comida generado */ async generateMealPlan(formData) { Logger.apiCall('Generating meal plan', formData); const mealPlanData = this._buildMealPlanFromForm(formData); return await this.createMealPlan(mealPlanData); } // Métodos privados _buildMealPlanFromForm(formData) { return { name: formData.name || `Meal Plan ${new Date().toLocaleDateString()}`, description: this._buildMealPlanDescription(formData), start_date: formData.startDate || new Date().toISOString().split('T')[0], end_date: formData.endDate || new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], days: this._buildMealPlanDays(formData), created_by: formData.username || 'user' }; } _buildMealPlanDescription(formData) { const parts = []; if (formData.dietType) parts.push(`Diet: ${formData.dietType}`); if (formData.cookingTime) parts.push(`Cooking time: ${formData.cookingTime}`); if (formData.servings) parts.push(`Servings: ${formData.servings}`); return parts.join(', '); } _buildMealPlanDays(formData) { const days = []; const startDate = new Date(formData.startDate || Date.now()); for (let i = 0; i < 7; i++) { const date = new Date(startDate); date.setDate(startDate.getDate() + i); days.push({ date: date.toISOString().split('T')[0], meals: this._buildMealsForDay(formData, i) }); } return days; } _buildMealsForDay(formData, dayIndex) { const meals = []; const mealTypes = formData.mealTypes || ['breakfast', 'lunch', 'dinner']; mealTypes.forEach(mealType => { meals.push({ type: mealType, name: `${mealType.charAt(0).toUpperCase() + mealType.slice(1)} Day ${dayIndex + 1}`, ingredients: this._generateSampleIngredients(mealType) }); }); return meals; } _generateSampleIngredients(mealType) { const ingredients = { breakfast: ['Oats', 'Milk', 'Berries', 'Honey'], lunch: ['Chicken', 'Rice', 'Vegetables', 'Olive Oil'], dinner: ['Salmon', 'Quinoa', 'Broccoli', 'Lemon'] }; return ingredients[mealType] || ['Ingredient 1', 'Ingredient 2']; } _handleMealPlanError(error, operation) { Logger.apiError(`Meal plan operation failed: ${operation}`, error); if (error.status === 401) { throw new Error('Authentication failed. Please log in again to access meal plans.'); } else if (error.status === 403) { throw new Error('Access denied. You do not have permission to access meal plans.'); } else if (error.status === 404) { throw new Error('Meal plans service not found. The service may be temporarily unavailable.'); } else { throw new Error(`Failed to ${operation}: ${error.message}`); } } } class UsersModule { constructor(http, config) { this.http = http; this.config = config; } /** * Obtener perfil de usuario * @param {string} userId - ID del usuario * @returns {Promise<Object>} Perfil del usuario */ async getUserProfile(userId) { if (!userId) { throw new ValidationError('User ID is required'); } Logger.userData('Fetching user profile', { userId }); const encodedUserId = encodeURIComponent(userId); const url = `${this.config.endpoints.USERS}${encodedUserId}`; try { const response = await this.http.get(url); Logger.userData('User profile retrieved successfully', response); return { UserObj: response }; } catch (error) { if (error.status === 404) { Logger.userData('User profile not found', { userId }); return null; } throw error; } } /** * Actualizar perfil de usuario * @param {Object} profileData - Datos del perfil * @returns {Promise<Object>} Perfil actualizado */ async updateUserProfile(profileData) { if (!profileData || !profileData.id) { throw new ValidationError('Profile data with ID is required'); } Logger.userData('Updating user profile', profileData); const encodedUserId = encodeURIComponent(profileData.id); const url = `${this.config.endpoints.USERS}${encodedUserId}`; try { const response = await this.http.put(url, profileData); Logger.userData('User profile updated successfully', response); return { profileUpdateMSG: 'Profile updated successfully!', data: response }; } catch (error) { Logger.apiError('Failed to update user profile', error); throw new Error(`Failed to update profile: ${error.message}`); } } /** * Crear usuario básico * @param {string} email - Email del usuario * @returns {Promise<Object>} Usuario creado */ async createBasicUser(email) { if (!email || !this._isValidEmail(email)) { throw new ValidationError('Valid email is required'); } Logger.userData('Creating basic user', { email }); const emailParts = email.split('@'); const userData = { username: email, password: this._generateSecurePassword(), user_source: 'Delectable', agree_to_license: true, email: email, first_name: emailParts[0], last_name: '', phone: '', created_by: 'delectabot' }; const queryParams = new URLSearchParams(); Object.keys(userData).forEach(key => { if (userData[key] !== null && userData[key] !== undefined && userData[key] !== '') { queryParams.append(key, userData[key]); } }); const url = `${this.config.endpoints.USERS}?${queryParams.toString()}`; try { const response = await this.http.post(url, {}); Logger.userData('Basic user created successfully', response); return { createUserMSG: 'Basic user created successfully!', success: true, data: response, userData: { ...userData, password: '[HIDDEN]' } }; } catch (error) { Logger.apiError('Failed to create basic user', error); if (error.status === 409) { return { createUserMSG: 'User already exists with this email', success: false, errorCode: 'USER_EXISTS' }; } throw new Error(`Failed to create user: ${error.message}`); } } /** * Eliminar cuenta de usuario * @param {string} userId - ID del usuario * @returns {Promise<Object>} Resultado de la eliminación */ async deleteUserAccount(userId) { if (!userId) { throw new ValidationError('User ID is required'); } Logger.userData('Deleting user account', { userId }); const encodedUserId = encodeURIComponent(userId); const url = `${this.config.endpoints.USERS}${encodedUserId}`; try { await this.http.delete(url); Logger.userData('User account deleted successfully', { userId }); return { deleteAccountMSG: 'Account deleted successfully', success: true }; } catch (error) { Logger.apiError('Failed to delete user account', error); throw new Error(`Failed to delete account: ${error.message}`); } } /** * Obtener perfil VTEX del usuario * @param {string} email - Email del usuario * @returns {Promise<Object>} Perfil VTEX */ async getVtexProfile(email) { if (!email) { throw new ValidationError('Email is required for VTEX profile request'); } Logger.userData('Getting VTEX profile', { email }); const encodedEmail = encodeURIComponent(email); const url = `${this.config.endpoints.VTEX_PROFILE}?email=${encodedEmail}`; try { const response = await this.http.get(url); Logger.userData('VTEX profile retrieved successfully', response); return { vtexProfileMSG: 'VTEX profile retrieved successfully', success: true, ...response }; } catch (error) { Logger.apiError('Failed to get VTEX profile', error); return { vtexProfileMSG: `Failed to get VTEX profile: ${error.message}`, success: false, vtex_profile: null }; } } /** * Crear usuario desde perfil VTEX * @param {Object} vtexProfile - Perfil VTEX * @returns {Promise<Object>} Usuario creado */ async createUserFromVtex(vtexProfile) { if (!vtexProfile || !vtexProfile.email) { throw new ValidationError('Valid VTEX profile with email is required'); } Logger.userData('Creating user from VTEX profile', { email: vtexProfile.email }); const userData = { username: vtexProfile.email, password: this._generateSecurePassword(), user_source: 'Delectable', agree_to_license: true, email: vtexProfile.email, first_name: vtexProfile.firstName || '', last_name: vtexProfile.lastName || '', phone: vtexProfile.homePhone || vtexProfile.phone || '', created_by: 'delectabot' }; const queryParams = new URLSearchParams(); Object.keys(userData).forEach(key => { if (userData[key] !== null && userData[key] !== undefined && userData[key] !== '') { queryParams.append(key, userData[key]); } }); const url = `${this.config.endpoints.USERS}?${queryParams.toString()}`; try { const response = await this.http.post(url, {}); Logger.userData('User created from VTEX successfully', response); return { createUserMSG: 'User created successfully from VTEX profile!', success: true, data: response, userData: { ...userData, password: '[HIDDEN]' } }; } catch (error) { Logger.apiError('Failed to create user from VTEX', error); if (error.status === 409) { return { createUserMSG: 'User already exists with this email', success: false, errorCode: 'USER_EXISTS' }; } throw new Error(`Failed to create user from VTEX: ${error.message}`); } } // Métodos privados _isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } _generateSecurePassword() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; let password = ''; for (let i = 0; i < 12; i++) { password += chars.charAt(Math.floor(Math.random() * chars.length)); } return password; } } // src/modules/recipes.js /** * Módulo para gestión de recetas * Proporciona métodos para obtener, buscar y seleccionar recetas */ class RecipesModule { /** * Constructor del módulo de recetas * @param {HttpClient} httpClient - Cliente HTTP para realizar peticiones * @param {Config} config - Configuración del SDK */ constructor(httpClient, config) { this.httpClient = httpClient; this.config = config; this.logger = new Logger('RecipesModule'); } /** * Obtiene todas las recetas del usuario * @param {string} accessToken - Token de acceso del usuario * @param {string} username - Nombre de usuario * @param {string} recipeQuery - Consulta de búsqueda (default: "*") * @param {number} numSuggestions - Número de sugerencias (default: 100) * @param {boolean} includeSponsored - Incluir recetas patrocinadas (default: true) * @returns {Promise<Array>} Array de recetas del usuario * @throws {AuthenticationError} Si el token es inválido * @throws {ValidationError} Si los parámetros son inválidos * @throws {NetworkError} Si hay problemas de red */ async fetchUserRecipes(accessToken, username, recipeQuery = "*", numSuggestions = 100, includeSponsored = true) { this.logger.info(`Fetching user recipes for: ${username}`); this.logger.debug('Request params:', { recipeQuery, numSuggestions, includeSponsored }); if (!accessToken || !username) { throw new ValidationError("Access token and username are required for recipes service"); } try { const queryParams = new URLSearchParams({ recipe_query: recipeQuery, num_suggestions: numSuggestions.toString(), include_sponsored: includeSponsored.toString() }); const endpoint = `${this.config.endpoints.RECIPES}/user?${queryParams}`; this.logger.debug(`Using endpoint: ${endpoint}`); const response = await this.httpClient.request({ method: 'GET', url: endpoint, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); this.logger.debug('API response received:', { type: typeof response, isArray: Array.isArray(response), length: response?.length || 0 }); if (Array.isArray(response)) { this.logger.info(`Successfully fetched ${response.length} recipes`); return response; } else { this.logger.warn('Unexpected response format, returning empty array'); return []; } } catch (error) { this.logger.error('Error fetching user recipes:', error); if (error.status === 401) { throw new AuthenticationError('Invalid or expired access token'); } else if (error.status >= 400 && error.status < 500) { throw new ValidationError(`Invalid request: ${error.message}`); } else { throw new NetworkError(`Failed to fetch user recipes: ${error.message}`); } } } /** * Obtiene una receta específica por su ID * @param {string} accessToken - Token de acceso del usuario * @param {string} username - Nombre de usuario * @param {string} recipeId - ID de la receta a obtener * @returns {Promise<Object>} Objeto con los detalles completos de la receta * @throws {AuthenticationError} Si el token es inválido * @throws {ValidationError} Si los parámetros son inválidos * @throws {NetworkError} Si hay problemas de red */ async fetchRecipeById(accessToken, username, recipeId) { this.logger.info(`Fetching recipe by ID: ${recipeId}`); if (!accessToken || !username || !recipeId) { throw new ValidationError("Access token, username, and recipeId are required for recipe by ID service"); } try { const endpoint = `${this.config.endpoints.RECIPES}/${recipeId}`; this.logger.debug(`Using endpoint: ${endpoint}`); const response = await this.httpClient.request({ method: 'GET', url: endpoint, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); this.logger.debug('Recipe details received:', { id: response?.id, name: response?.name, hasIngredients: !!response?.ingredientNames?.length, hasInstructions: !!response?.instructions?.length }); if (response && typeof response === 'object') { this.logger.info('Successfully fetched recipe details'); return response; } else { throw new ValidationError(`Invalid recipe response format for ID: ${recipeId}`); } } catch (error) { this.logger.error('Error fetching recipe by ID:', error); if (error.status === 401) { throw new AuthenticationError('Invalid or expired access token'); } else if (error.status === 404) { throw new ValidationError(`Recipe not found: ${recipeId}`); } else if (error.status >= 400 && error.status < 500) { throw new ValidationError(`Invalid request: ${error.message}`); } else { throw new NetworkError(`Failed to fetch recipe by ID: ${error.message}`); } } } /** * Selecciona una receta para un meal plan específico * @param {string} accessToken - Token de acceso del usuario * @param {string} username - Nombre de usuario * @param {string} mealId - ID del meal para el que seleccionar receta * @param {Array<string>} userPreferences - Preferencias del usuario (default: []) * @param {number} rating - Calificación deseada (default: 5) * @param {boolean} generateIfNotFound - Generar si no se encuentra (default: true) * @returns {Promise<Object>} Objeto con candidatos de recetas y scores de similitud * @throws {AuthenticationError} Si el token es inválido * @throws {ValidationError} Si los parámetros son inválidos * @throws {NetworkError} Si hay problemas de red */ async selectRecipeForMeal(accessToken, username, mealId, userPreferences = [], rating = 5, generateIfNotFound = true) { this.logger.info(`Selecting recipe for meal: ${mealId}`); this.logger.debug('Request params:', { mealId, userPreferences, rating, generateIfNotFound }); if (!accessToken || !username || !mealId) { throw new ValidationError("Access token, username, and mealId are required for select recipe for meal service"); } try { // Map user preferences to notes string const notes = Array.isArray(userPreferences) && userPreferences.length > 0 ? userPreferences.join(', ') : 'User preferences applied'; const endpoint = `${this.config.endpoints.RECIPES}/select-recipe-for-meal`; const requestBody = { meal_id: mealId, generate_if_not_found: generateIfNotFound, rating: rating, notes: notes }; this.logger.debug(`Using endpoint: ${endpoint}`); this.logger.debug('Request body:', requestBody); const response = await this.httpClient.request({ method: 'POST', url: endpoint, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, data: requestBody }); this.logger.debug('Meal recipe selection response received:', { mealId: response?.meal_id, mealName: response?.meal_name, candidatesCount: response?.existing_recipes_from_database?.length || 0, hasGeneratedRecipes: response?.recipes_that_could_be_generated?.length > 0 }); if (response && typeof response === 'object') { this.logger.info('Successfully received recipe candidates for meal'); return response; } else { throw new ValidationError(`Invalid recipe response format for meal ID: ${mealId}`); } } catch (error) { this.logger.error('Error selecting recipe for meal:', error); if (error.status === 401) { throw new AuthenticationError('Invalid or expired access token'); } else if (error.status === 404) { throw new ValidationError(`Meal not found: ${mealId}`); } else if (error.status >= 400 && error.status < 500) { throw new ValidationError(`Invalid request: ${error.message}`); } else { throw new NetworkError(`Failed to select recipe for meal: ${error.message}`); } } } /** * Función helper para extraer la mejor receta basada en similarity_score * @param {Object} mealRecipeResponse - Respuesta del API selectRecipeForMeal * @returns {Object|null} La receta con mayor similarity_score o null si no hay candidatos */ static extractBestRecipe(mealRecipeResponse) { if (!mealRecipeResponse?.existing_recipes_from_database?.length) { return null; } let bestRecipe = null; let bestScore = -1; mealRecipeResponse.existing_recipes_from_database.forEach(recipe => { if (recipe.similarity_score > bestScore) { bestScore = recipe.similarity_score; bestRecipe = recipe; } }); return bestRecipe; } /** * Método de conveniencia que combina selectRecipeForMeal + fetchRecipeById * para obtener directamente la receta completa mejor puntuada * @param {string} accessToken - Token de acceso del usuario * @param {string} username - Nombre de usuario * @param {string} mealId - ID del meal * @param {Array<string>} userPreferences - Preferencias del usuario * @param {number} rating - Calificación deseada * @returns {Promise<{candidates: Object, bestCandidate: Object, completeRecipe: Object}>} */ async getCompleteRecipeForMeal(accessToken, username, mealId, userPreferences = [], rating = 5) { this.logger.info(`Getting complete recipe for meal (2-step flow): ${mealId}`); // Step 1: Get candidates const candidates = await this.selectRecipeForMeal(accessToken, username, mealId, userPreferences, rating); // Step 2: Extract best candidate const bestCandidate = RecipesModule.extractBestRecipe(candidates); if (!bestCandidate) { throw new ValidationError(`No suitable recipe candidates found for meal: ${mealId}`); } this.logger.debug(`Best candidate selected: "${bestCandidate.name}" with score: ${bestCandidate.similarity_score}`); // Step 3: Get complete recipe const completeRecipe = await this.fetchRecipeById(accessToken, username, bestCandidate.id); return { candidates, bestCandidate, completeRecipe }; } } class Config { constructor(options = {}) { // URLs base this.baseURL = options.baseURL || this._getDefaultBaseURL(); this.apiKey = options.apiKey || this._getDefaultApiKey(); // Configuraciones VTEX this.vtex = { appKey: options.vtex?.appKey || this._getEnvVar('VTEX_APP_KEY'), appToken: options.vtex?.appToken || this._getEnvVar('VTEX_APP_TOKEN'), domain: options.vtex?.domain || 'delectablegrocery.myvtex.com' }; // Configuraciones generales this.timeout = options.timeout || 30000; this.retries = options.retries || 3; this.debug = options.debug || false; // Cache settings this.cache = { enabled: options.cache?.enabled ?? true, ttl: options.cache?.ttl || 300000, // 5 minutos }; // Endpoints - Copiados directamente del config original this.endpoints = { LOGIN_ANONYMOUS: "/api/v1/auth/anonymous", LOGIN: "/api/v1/auth/token", LLM: "/api/v1/llm", LLM_ACTION: "/api/v1/llm/select_action", RECIPES: "/api/v1/recipes", IMAGES: "/api/v1/images/", PRODUCTS: "/api/v1/products/", USERS: "/api/v1/users/", LISTS_SKU: "/api/v1/shoplist/{list_id}/items/{item_id}", LISTS_IMAGE: "/api/v1/shoplist/{list_id}/items/image", ADD_PREF: "/api/v1/users/{username}/prefs", REMOVE_PREF: "/api/v1/users/{username}/prefs", GET_PREFS: "/api/v1/users/{username}/prefs", VTEX_PROFILE: "/api/v1/vtex/profile", MEAL_PLANS: "/api/v1/meal-plans" }; } _getDefaultBaseURL() { // Detectar entorno automáticamente if (typeof window !== 'undefined') { return window.location.protocol === 'https:' ? 'https://dai-chat-service-public-2-826633408444.us-central1.run.app' : 'https://localhost:4001'; } // Node.js environment return process.env.DELECTABLE_BASE_URL || 'https://dai-chat-service-public-2-826633408444.us-central1.run.app'; } _getDefaultApiKey() { return this._getEnvVar('DELECTABLE_API_KEY') || 'abc123'; } _getEnvVar(name) { if (typeof process !== 'undefined' && process.env) { return process.env[name]; } return null; } // Métodos para obtener URLs completas getEndpointURL(endpoint) { return `${this.baseURL}${this.endpoints[endpoint] || endpoint}`; } // Método para validar configuración validate() { const errors = []; if (!this.baseURL) { errors.push('baseURL is required'); } if (!this.apiKey) { errors.push('apiKey is required'); } if (errors.length > 0) { throw new Error(`Configuration validation failed: ${errors.join(', ')}`); } return true; } } class HttpClient { constructor(config) { this.config = config; this.defaultHeaders = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'api-key': config.apiKey }; this._tokenRefreshCallback = null; } setDefaultHeader(key, value) { if (value === null || value === undefined) { delete this.defaultHeaders[key]; } else { this.defaultHeaders[key] = value; } } setTokenRefreshCallback(callback) { this._tokenRefreshCallback = callback; } async request(url, options = {}) { const finalURL = url.startsWith('http') ? url : `${this.config.baseURL}${url}`; const config = { method: 'GET', headers: { ...this.defaultHeaders, ...options.headers }, timeout: this.config.timeout, ...options }; // Añadir body si es necesario if (options.data && config.method !== 'GET') { config.body = JSON.stringify(options.data); } Logger.apiCall(`${config.method} ${finalURL}`, { headers: Object.keys(config.headers), hasBody: !!config.body }); try { const response = await this._makeRequest(finalURL, config, options.isRetry); const data = await this._handleResponse(response); Logger.apiSuccess(`${config.method} ${finalURL} completed`, { status: response.status, dataType: typeof data }); return data; } catch (error) { Logger.apiError(`${config.method} ${finalURL} failed`, error); throw this._handleError(error, finalURL, config, options); } } async _makeRequest(url, config, isRetry = false) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); try { const response = await fetch(url, { ...config, signal: controller.signal, mode: 'cors', credentials: 'omit', referrerPolicy: 'no-referrer' }); clearTimeout(timeoutId); // Manejo especial de error 401 - Token refresh if (response.status === 401 && this._tokenRefreshCallback && !isRetry) { Logger.warn('Token appears to be expired, attempting refresh...'); try { const newToken = await this._tokenRefreshCallback(); if (newToken) { Logger.apiCall('Token refreshed successfully, retrying request'); // Actualizar header y reintentar config.headers['Authorization'] = `Bearer ${newToken}`; return await this._makeRequest(url, config, true); } } catch (refreshError) { Logger.apiError('Token refresh failed', refreshError); throw new AuthenticationError('Authentication expired and refresh failed'); } } return response; } catch (error) { clearTimeout(timeoutId); throw error; } } async _handleResponse(response) { const contentType = response.headers.get('content-type'); let data; if (contentType?.includes('application/json')) { data = await response.json(); } else { data = await response.text(); } if (!response.ok) { throw new DelectableError( data.message || data.detail || `HTTP Error: ${response.status}`, response.status, data ); } return data; } _handleError(error, url, config, options) { if (error.name === 'AbortError') { return new NetworkError(`Request timeout after ${config.timeout}ms`); } // Errores específicos por código de estado if (error.status === 401) { return new AuthenticationError('Authentication failed. Please log in again.'); } else if (error.status === 403) { return new AuthenticationError('Access denied. You do not have permission to access this resource.'); } else if (error.status === 404) { return new NotFoundError('Resource not found. The service may be temporarily unavailable.'); } else if (error.status === 422) { return new ValidationError('Invalid data provided. Please check your input and try again.', error.data); } else if (error.status >= 500) { return new ServerError('Server error. Please try again later.'); } if (error instanceof DelectableError) { return error; } if (error.message?.includes('Failed to fetch') || error.message?.includes('NetworkError')) { return new NetworkError('Unable to connect to the service. Please check your internet connection and try again.'); } else if (error.message?.includes('CORS')) { return new NetworkError('There was a cross-origin request error. This may be a temporary server issue.'); } return new NetworkError(error.message || 'Network error'); } // Métodos de conveniencia get(url, options = {}) { return this.request(url, { ...options, method: 'GET' }); } post(url, data, options = {}) { return this.request(url, { ...options, method: 'POST', data }); } put(url, data, options = {}) { return this.request(url, { ...options, method: 'PUT', data }); } delete(url, options = {}) { return this.request(url, { ...options, method: 'DELETE' }); } } class DelectableSDK { constructor(options = {}) { // Configuración this.config = new Config(options); this.config.validate(); // Validar configuración // Cliente HTTP compartido this.http = new HttpClient(this.config); // Configurar callback de refresh token this.http.setTokenRefreshCallback(this._refreshToken.bind(this)); // Inicializar módulos this.auth = new AuthModule(this.http, this.config); this.mealPlans = new MealPlansModule(this.http, this.config); this.users = new UsersModule(this.http, this.config); this.recipes = new RecipesModule(this.http, this.config); // Estado interno this._accessToken = null; this._user = null; this._refreshToken = null; // Auto-configurar token si se proporciona if (options.accessToken) { this.setAccessToken(options.accessToken); } Logger.debug('DelectableSDK initialized', { baseURL: this.config.baseURL, hasToken: !!this._accessToken }); } /** * Configurar access token * @param {string} token - Access token * @returns {DelectableSDK} Instancia para chaining */ setAccessToken(token) { this._accessToken = token; this.http.setDefaultHeader('Authorization', `Bearer ${token}`); Logger.userAuth('Access token configured'); return this; } /** * Configurar usuario actual * @param {Object} user - Datos del usuario * @returns {DelectableSDK} Instancia para chaining */ setUser(user) { this._user = user; Logger.userData('Current user set', user); return this; } /** * Inicialización del SDK * @param {Object} op