const IDEMPOTENT_POST_ENDPOINTS = new Set([ '/calendars.php', '/calendar_subscriptions.php', '/events.php', '/tasks.php', '/contacts.php', '/bookings.php', '/availability_links.php' ]); export class PietroCalApiError extends Error { constructor(message, status, responseData = null, responseHeaders = {}) { super(message); this.name = 'PietroCalApiError'; this.status = status; this.responseData = responseData; this.responseHeaders = responseHeaders; } } export class PietroCalClient { constructor({ token, baseUrl = 'https://pietrocal.com/api/v1', fetchImpl = globalThis.fetch, timeoutMs = 30000, maxTransportRetries = 1 } = {}) { if (!String(token || '').startsWith('pc_live_')) { throw new TypeError('El token debe comenzar con pc_live_.'); } if (typeof fetchImpl !== 'function') { throw new TypeError('Este entorno no dispone de fetch().'); } if (!Number.isInteger(maxTransportRetries) || maxTransportRetries < 0 || maxTransportRetries > 5) { throw new TypeError('maxTransportRetries debe estar entre 0 y 5.'); } this.token = token; this.baseUrl = String(baseUrl).replace(/\/+$/, ''); this.fetchImpl = fetchImpl; this.timeoutMs = timeoutMs; this.maxTransportRetries = maxTransportRetries; } async request(method, endpoint, { query = {}, body, signal, idempotencyKey = null, autoIdempotency = true, maxTransportRetries = this.maxTransportRetries } = {}) { method = String(method).toUpperCase(); if (!['GET', 'POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) { throw new TypeError('Método HTTP no permitido.'); } const normalizedEndpoint = `/${String(endpoint).replace(/^\/+/, '')}`; if (/[\r\n]/.test(normalizedEndpoint)) { throw new TypeError('Endpoint inválido.'); } const url = new URL(`${this.baseUrl}${normalizedEndpoint}`); Object.entries(query || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { url.searchParams.set(key, String(value)); } }); const resolvedKey = this.resolveIdempotencyKey( method, normalizedEndpoint, idempotencyKey, autoIdempotency ); const retryLimit = method === 'POST' && resolvedKey ? clampInteger(maxTransportRetries, 0, 5) : 0; const encodedBody = body === undefined ? undefined : JSON.stringify(body); let transportAttempts = 0; while (true) { transportAttempts += 1; const controller = new AbortController(); let externalAbortHandler = null; let timedOut = false; const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, this.timeoutMs); if (signal) { if (signal.aborted) { clearTimeout(timeout); throw signal.reason || new DOMException('Abortado.', 'AbortError'); } externalAbortHandler = () => controller.abort(signal.reason); signal.addEventListener('abort', externalAbortHandler, { once: true }); } const headers = { Accept: 'application/json', Authorization: `Bearer ${this.token}` }; const options = { method, headers, signal: controller.signal, credentials: 'omit' }; if (encodedBody !== undefined) { headers['Content-Type'] = 'application/json'; options.body = encodedBody; } if (resolvedKey) { headers['Idempotency-Key'] = resolvedKey; } try { const response = await this.fetchImpl(url, options); const raw = await response.text(); let data = raw; if (raw) { try { data = JSON.parse(raw); } catch (_) {} } else { data = null; } const responseHeaders = {}; [ 'content-type', 'x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-pietrocal-api-version', 'x-pagination-page', 'x-pagination-per-page', 'x-pagination-total', 'x-pagination-total-pages', 'retry-after', 'idempotency-key', 'idempotency-replayed' ].forEach(name => { const value = response.headers.get(name); if (value !== null) responseHeaders[name] = value; }); if (!response.ok) { const message = data?.error?.message || data?.message || 'La API devolvió un error.'; throw new PietroCalApiError(message, response.status, data, responseHeaders); } return { status: response.status, data, headers: responseHeaders, requestId: responseHeaders['x-request-id'] || null, rateLimit: { limit: numberOrNull(responseHeaders['x-ratelimit-limit']), remaining: numberOrNull(responseHeaders['x-ratelimit-remaining']) }, pagination: { page: numberOrNull(responseHeaders['x-pagination-page']), perPage: numberOrNull(responseHeaders['x-pagination-per-page']), total: numberOrNull(responseHeaders['x-pagination-total']), totalPages: numberOrNull(responseHeaders['x-pagination-total-pages']) }, idempotency: { key: responseHeaders['idempotency-key'] || resolvedKey || null, replayed: booleanOrNull(responseHeaders['idempotency-replayed']) }, transportAttempts }; } catch (error) { if (error instanceof PietroCalApiError) { throw error; } const externallyAborted = Boolean(signal?.aborted); const mayRetry = !externallyAborted && transportAttempts <= retryLimit && method === 'POST' && Boolean(resolvedKey); if (!mayRetry) { if (timedOut) { throw new Error('Error de red: timeout de la solicitud.', { cause: error }); } throw error; } await sleep(retryDelayMs(transportAttempts)); } finally { clearTimeout(timeout); if (signal && externalAbortHandler) { signal.removeEventListener('abort', externalAbortHandler); } } } } static generateIdempotencyKey() { if (typeof globalThis.crypto?.randomUUID === 'function') { return `sdk_${globalThis.crypto.randomUUID().replaceAll('-', '')}`; } if (typeof globalThis.crypto?.getRandomValues === 'function') { const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes); return `sdk_${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')}`; } return `sdk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 18)}`; } profile() { return this.request('GET', '/me_app.php'); } calendars() { return this.request('GET', '/calendars.php'); } events({ page = 1, perPage = 50, ...filters } = {}) { return this.request('GET', '/events.php', { query: { ...filters, page, perPage } }); } tasks(filters = {}) { return this.request('GET', '/tasks.php', { query: filters }); } contacts({ page = 1, perPage = 50, ...filters } = {}) { return this.request('GET', '/contacts.php', { query: { ...filters, page, perPage } }); } bookings({ page = 1, perPage = 50, ...filters } = {}) { return this.request('GET', '/bookings.php', { query: { ...filters, page, perPage } }); } createCalendar(calendar, idempotencyKey = null) { return this.create('/calendars.php', calendar, idempotencyKey); } createCalendarSubscription(subscription, idempotencyKey = null) { return this.create('/calendar_subscriptions.php', subscription, idempotencyKey); } createEvent(event, idempotencyKey = null) { return this.create('/events.php', event, idempotencyKey); } createTask(task, idempotencyKey = null) { return this.create('/tasks.php', task, idempotencyKey); } createContact(contact, idempotencyKey = null) { return this.create('/contacts.php', contact, idempotencyKey); } createBooking(booking, idempotencyKey = null) { return this.create('/bookings.php', booking, idempotencyKey); } createAvailabilityLink(link, idempotencyKey = null) { return this.create('/availability_links.php', link, idempotencyKey); } updateEvent(id, event) { return this.request('PATCH', '/events.php', { query: { id }, body: event }); } deleteEvent(id) { return this.request('DELETE', '/events.php', { query: { id } }); } create(endpoint, body, idempotencyKey) { return this.request('POST', endpoint, { body, idempotencyKey }); } resolveIdempotencyKey(method, endpoint, explicitKey, autoIdempotency) { if (explicitKey !== null && explicitKey !== undefined) { const key = String(explicitKey); if (!validIdempotencyKey(key)) { throw new TypeError('Idempotency-Key inválida.'); } return key; } if (autoIdempotency && method === 'POST' && IDEMPOTENT_POST_ENDPOINTS.has(endpoint)) { return PietroCalClient.generateIdempotencyKey(); } return null; } } function validIdempotencyKey(key) { return /^[A-Za-z0-9][A-Za-z0-9._:-]{7,199}$/.test(key); } function numberOrNull(value) { if (value === undefined || value === null || value === '') return null; const number = Number(value); return Number.isFinite(number) ? number : null; } function booleanOrNull(value) { if (value === undefined || value === null || value === '') return null; const normalized = String(value).trim().toLowerCase(); if (['true', '1', 'yes'].includes(normalized)) return true; if (['false', '0', 'no'].includes(normalized)) return false; return null; } function clampInteger(value, min, max) { const number = Number(value); if (!Number.isInteger(number)) { throw new TypeError('maxTransportRetries debe ser un entero.'); } return Math.max(min, Math.min(max, number)); } function retryDelayMs(attempt) { return Math.min(2000, 250 * (2 ** Math.max(0, attempt - 1))); } function sleep(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); }