@titamedia/delectable-sdk
Version:
SDK oficial para los servicios del chatbot Delectable
1,651 lines (1,409 loc) • 54.9 kB
JavaScript
// 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