@utilia-os/sdk-js 1.7.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -0
- package/dist/index.d.mts +1412 -16
- package/dist/index.d.ts +1412 -16
- package/dist/index.js +1122 -37
- package/dist/index.mjs +1121 -37
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -171,6 +171,8 @@ var OAuthService = class {
|
|
|
171
171
|
constructor(baseURL, config) {
|
|
172
172
|
/** Estado PKCE en memoria como fallback cuando el storage no soporta pendingState */
|
|
173
173
|
this._pendingState = null;
|
|
174
|
+
/** Promesa de refresco en curso para deduplicar llamadas concurrentes. */
|
|
175
|
+
this._refreshInFlight = null;
|
|
174
176
|
this.baseURL = baseURL.replace(/\/$/, "");
|
|
175
177
|
this.config = config;
|
|
176
178
|
this.storage = config.tokenStorage ?? new MemoryTokenStorage();
|
|
@@ -186,7 +188,7 @@ var OAuthService = class {
|
|
|
186
188
|
*
|
|
187
189
|
* Este es el método recomendado para la mayoría de los casos.
|
|
188
190
|
*
|
|
189
|
-
* @param options - Opciones adicionales (state personalizado, scopes)
|
|
191
|
+
* @param options - Opciones adicionales (state personalizado, scopes, parámetros OIDC)
|
|
190
192
|
* @returns URL de autorización lista para redirigir al usuario
|
|
191
193
|
*/
|
|
192
194
|
async getAuthorizationUrl(options) {
|
|
@@ -207,8 +209,8 @@ var OAuthService = class {
|
|
|
207
209
|
* Método de control manual: devuelve codeVerifier y state que el desarrollador
|
|
208
210
|
* debe gestionar. Para un flujo automático, usar getAuthorizationUrl() en su lugar.
|
|
209
211
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
+
* Soporta los parámetros OIDC Core 1.0 §3.1.2.1: prompt, loginHint, nonce,
|
|
213
|
+
* maxAge y el Resource Indicator (RFC 8707) mediante el campo resource.
|
|
212
214
|
*/
|
|
213
215
|
async getLoginUrl(options) {
|
|
214
216
|
const codeVerifier = generateCodeVerifier();
|
|
@@ -224,6 +226,24 @@ var OAuthService = class {
|
|
|
224
226
|
code_challenge: codeChallenge,
|
|
225
227
|
code_challenge_method: "S256"
|
|
226
228
|
});
|
|
229
|
+
if (options?.theme) {
|
|
230
|
+
params.set("theme", options.theme);
|
|
231
|
+
}
|
|
232
|
+
if (options?.prompt) {
|
|
233
|
+
params.set("prompt", options.prompt);
|
|
234
|
+
}
|
|
235
|
+
if (options?.loginHint) {
|
|
236
|
+
params.set("login_hint", options.loginHint);
|
|
237
|
+
}
|
|
238
|
+
if (options?.nonce) {
|
|
239
|
+
params.set("nonce", options.nonce);
|
|
240
|
+
}
|
|
241
|
+
if (options?.resource) {
|
|
242
|
+
params.set("resource", options.resource);
|
|
243
|
+
}
|
|
244
|
+
if (typeof options?.maxAge === "number" && Number.isFinite(options.maxAge) && options.maxAge >= 0) {
|
|
245
|
+
params.set("max_age", String(Math.floor(options.maxAge)));
|
|
246
|
+
}
|
|
227
247
|
return {
|
|
228
248
|
url: `${this.baseURL}/oauth/authorize?${params.toString()}`,
|
|
229
249
|
codeVerifier,
|
|
@@ -243,7 +263,13 @@ var OAuthService = class {
|
|
|
243
263
|
}
|
|
244
264
|
const { url, codeVerifier, state } = await this.getLoginUrl({
|
|
245
265
|
state: options?.state,
|
|
246
|
-
scopes: options?.scopes
|
|
266
|
+
scopes: options?.scopes,
|
|
267
|
+
theme: options?.theme,
|
|
268
|
+
prompt: options?.prompt,
|
|
269
|
+
loginHint: options?.loginHint,
|
|
270
|
+
nonce: options?.nonce,
|
|
271
|
+
resource: options?.resource,
|
|
272
|
+
maxAge: options?.maxAge
|
|
247
273
|
});
|
|
248
274
|
const width = options?.width ?? 500;
|
|
249
275
|
const height = options?.height ?? 700;
|
|
@@ -361,37 +387,53 @@ var OAuthService = class {
|
|
|
361
387
|
return tokens;
|
|
362
388
|
}
|
|
363
389
|
/**
|
|
364
|
-
* Refresca el token de acceso usando el refresh_token
|
|
390
|
+
* Refresca el token de acceso usando el refresh_token.
|
|
391
|
+
*
|
|
392
|
+
* Deduplica peticiones concurrentes mediante una promesa cacheada: si dos
|
|
393
|
+
* llamadas entran a la vez, comparten la misma petición HTTP. Esto es
|
|
394
|
+
* imprescindible porque el backend implementa rotación de refresh tokens con
|
|
395
|
+
* detección de reuso; dos refresh concurrentes con el mismo token producirían
|
|
396
|
+
* una invalidación inmediata de toda la cadena.
|
|
365
397
|
*
|
|
366
398
|
* @returns Nuevos tokens OAuth
|
|
367
399
|
* @throws Error si no hay refresh_token disponible
|
|
368
400
|
*/
|
|
369
401
|
async refreshToken() {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
throw new Error("No hay refresh token disponible. El usuario debe volver a autenticarse.");
|
|
373
|
-
}
|
|
374
|
-
const body = {
|
|
375
|
-
grant_type: "refresh_token",
|
|
376
|
-
refresh_token: current.refreshToken,
|
|
377
|
-
client_id: this.config.clientId
|
|
378
|
-
};
|
|
379
|
-
if (this.config.clientSecret) {
|
|
380
|
-
body.client_secret = this.config.clientSecret;
|
|
402
|
+
if (this._refreshInFlight) {
|
|
403
|
+
return this._refreshInFlight;
|
|
381
404
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
405
|
+
this._refreshInFlight = (async () => {
|
|
406
|
+
try {
|
|
407
|
+
const current = await this.storage.getTokens();
|
|
408
|
+
if (!current?.refreshToken) {
|
|
409
|
+
throw new Error("No hay refresh token disponible. El usuario debe volver a autenticarse.");
|
|
410
|
+
}
|
|
411
|
+
const body = {
|
|
412
|
+
grant_type: "refresh_token",
|
|
413
|
+
refresh_token: current.refreshToken,
|
|
414
|
+
client_id: this.config.clientId
|
|
415
|
+
};
|
|
416
|
+
if (this.config.clientSecret) {
|
|
417
|
+
body.client_secret = this.config.clientSecret;
|
|
418
|
+
}
|
|
419
|
+
const response = await this.http.post("/oauth/token", body);
|
|
420
|
+
const data = response.data;
|
|
421
|
+
const tokens = {
|
|
422
|
+
accessToken: data.access_token,
|
|
423
|
+
refreshToken: data.refresh_token ?? current.refreshToken,
|
|
424
|
+
expiresIn: data.expires_in,
|
|
425
|
+
expiresAt: Date.now() + data.expires_in * 1e3,
|
|
426
|
+
tokenType: data.token_type || "Bearer",
|
|
427
|
+
scope: data.scope,
|
|
428
|
+
idToken: data.id_token
|
|
429
|
+
};
|
|
430
|
+
await this.storage.setTokens(tokens);
|
|
431
|
+
return tokens;
|
|
432
|
+
} finally {
|
|
433
|
+
this._refreshInFlight = null;
|
|
434
|
+
}
|
|
435
|
+
})();
|
|
436
|
+
return this._refreshInFlight;
|
|
395
437
|
}
|
|
396
438
|
/**
|
|
397
439
|
* Obtiene información del usuario autenticado
|
|
@@ -406,9 +448,9 @@ var OAuthService = class {
|
|
|
406
448
|
return response.data;
|
|
407
449
|
}
|
|
408
450
|
/**
|
|
409
|
-
* Revoca el token actual
|
|
451
|
+
* Revoca el token actual almacenado y limpia el storage.
|
|
410
452
|
*
|
|
411
|
-
* @param tokenType - Tipo de token a revocar (
|
|
453
|
+
* @param tokenType - Tipo de token a revocar (`access_token` o `refresh_token`).
|
|
412
454
|
* Por defecto revoca el access_token.
|
|
413
455
|
*/
|
|
414
456
|
async revokeToken(tokenType) {
|
|
@@ -416,21 +458,76 @@ var OAuthService = class {
|
|
|
416
458
|
if (!current) return;
|
|
417
459
|
const token = tokenType === "refresh_token" ? current.refreshToken : current.accessToken;
|
|
418
460
|
if (!token) return;
|
|
461
|
+
try {
|
|
462
|
+
await this.revoke(token, tokenType);
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
await this.storage.clearTokens();
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Revoca un token arbitrario contra el endpoint `/oauth/revoke` (RFC 7009).
|
|
469
|
+
* A diferencia de `revokeToken()`, no toca el almacenamiento local.
|
|
470
|
+
*
|
|
471
|
+
* @param token - Token a revocar (access token o refresh token).
|
|
472
|
+
* @param tokenTypeHint - Pista opcional sobre el tipo de token.
|
|
473
|
+
*/
|
|
474
|
+
async revoke(token, tokenTypeHint) {
|
|
419
475
|
const body = {
|
|
420
476
|
token,
|
|
421
477
|
client_id: this.config.clientId
|
|
422
478
|
};
|
|
423
|
-
if (
|
|
424
|
-
body.token_type_hint =
|
|
479
|
+
if (tokenTypeHint) {
|
|
480
|
+
body.token_type_hint = tokenTypeHint;
|
|
425
481
|
}
|
|
426
482
|
if (this.config.clientSecret) {
|
|
427
483
|
body.client_secret = this.config.clientSecret;
|
|
428
484
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
485
|
+
await this.http.post("/oauth/revoke", body);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Introspecciona un token contra `/oauth/introspect` (RFC 7662).
|
|
489
|
+
* Requiere que el cliente esté autenticado (client_secret o public con PKCE).
|
|
490
|
+
*
|
|
491
|
+
* @param token - Token a introspeccionar.
|
|
492
|
+
* @param tokenTypeHint - Pista opcional sobre el tipo de token.
|
|
493
|
+
* @returns Estado y metadatos del token.
|
|
494
|
+
*/
|
|
495
|
+
async introspect(token, tokenTypeHint) {
|
|
496
|
+
const body = {
|
|
497
|
+
token,
|
|
498
|
+
client_id: this.config.clientId
|
|
499
|
+
};
|
|
500
|
+
if (tokenTypeHint) {
|
|
501
|
+
body.token_type_hint = tokenTypeHint;
|
|
432
502
|
}
|
|
433
|
-
|
|
503
|
+
if (this.config.clientSecret) {
|
|
504
|
+
body.client_secret = this.config.clientSecret;
|
|
505
|
+
}
|
|
506
|
+
const response = await this.http.post("/oauth/introspect", body);
|
|
507
|
+
return response.data;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Lista las aplicaciones OAuth autorizadas por el usuario actual.
|
|
511
|
+
* Requiere un access token válido.
|
|
512
|
+
*/
|
|
513
|
+
async getAuthorizedApps() {
|
|
514
|
+
const accessToken = await this.getAccessToken();
|
|
515
|
+
const response = await this.http.get("/oauth/apps", {
|
|
516
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
517
|
+
});
|
|
518
|
+
return response.data;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Revoca el acceso de una aplicación OAuth concreta del usuario actual.
|
|
522
|
+
* Todos los tokens emitidos para esa aplicación quedan invalidados.
|
|
523
|
+
*/
|
|
524
|
+
async revokeApp(clientId) {
|
|
525
|
+
const accessToken = await this.getAccessToken();
|
|
526
|
+
await this.http.post(
|
|
527
|
+
`/oauth/apps/${encodeURIComponent(clientId)}/revoke`,
|
|
528
|
+
{},
|
|
529
|
+
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
530
|
+
);
|
|
434
531
|
}
|
|
435
532
|
/**
|
|
436
533
|
* Obtiene un access token válido, refrescándolo automáticamente si ha expirado
|
|
@@ -1667,6 +1764,987 @@ var AiService = class {
|
|
|
1667
1764
|
}
|
|
1668
1765
|
};
|
|
1669
1766
|
|
|
1767
|
+
// src/services/budgets.service.ts
|
|
1768
|
+
var BudgetsService = class {
|
|
1769
|
+
constructor(client) {
|
|
1770
|
+
this.basePath = "/crm/budgets";
|
|
1771
|
+
this.client = client;
|
|
1772
|
+
}
|
|
1773
|
+
// ==========================================
|
|
1774
|
+
// Listado / detalle / CRUD
|
|
1775
|
+
// ==========================================
|
|
1776
|
+
/**
|
|
1777
|
+
* Listar presupuestos aplicando filtros.
|
|
1778
|
+
*
|
|
1779
|
+
* @example
|
|
1780
|
+
* ```typescript
|
|
1781
|
+
* const page = await sdk.budgets.list({ status: 'SENT', page: 1, limit: 20 });
|
|
1782
|
+
* console.log(page.data, page.pagination.total);
|
|
1783
|
+
* ```
|
|
1784
|
+
*/
|
|
1785
|
+
async list(filters) {
|
|
1786
|
+
const params = this.serializeFilters(filters);
|
|
1787
|
+
const raw = await this.client.get(this.basePath, {
|
|
1788
|
+
params
|
|
1789
|
+
});
|
|
1790
|
+
return {
|
|
1791
|
+
data: raw.data ?? raw.budgets ?? [],
|
|
1792
|
+
pagination: raw.pagination
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
/** Obtener el detalle completo de un presupuesto por UUID. */
|
|
1796
|
+
async get(id) {
|
|
1797
|
+
validateRequired(id, "id");
|
|
1798
|
+
return this.client.get(`${this.basePath}/${id}`);
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Crear un nuevo presupuesto.
|
|
1802
|
+
* Puede incluir items y secciones en la misma peticion.
|
|
1803
|
+
*/
|
|
1804
|
+
async create(data) {
|
|
1805
|
+
validateRequired(data.title, "title");
|
|
1806
|
+
validateRequired(data.clientId, "clientId");
|
|
1807
|
+
validateRequired(data.validUntil, "validUntil");
|
|
1808
|
+
validateStringLength(data.title, "title", 1, 200);
|
|
1809
|
+
return this.client.post(this.basePath, data);
|
|
1810
|
+
}
|
|
1811
|
+
/** Actualizar un presupuesto (solo en estado DRAFT salvo excepciones). */
|
|
1812
|
+
async update(id, data) {
|
|
1813
|
+
validateRequired(id, "id");
|
|
1814
|
+
return this.client.patch(`${this.basePath}/${id}`, data);
|
|
1815
|
+
}
|
|
1816
|
+
/** Soft delete (puede recuperarse). */
|
|
1817
|
+
async delete(id) {
|
|
1818
|
+
validateRequired(id, "id");
|
|
1819
|
+
return this.client.delete(`${this.basePath}/${id}`);
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Eliminar de forma permanente.
|
|
1823
|
+
* Solo disponible para SUPER_ADMIN en la organizacion origen.
|
|
1824
|
+
*/
|
|
1825
|
+
async deletePermanent(id) {
|
|
1826
|
+
validateRequired(id, "id");
|
|
1827
|
+
return this.client.delete(
|
|
1828
|
+
`${this.basePath}/${id}/permanent`
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
// ==========================================
|
|
1832
|
+
// Duplicado / versiones / historial
|
|
1833
|
+
// ==========================================
|
|
1834
|
+
/** Duplicar un presupuesto sin encadenarlo como nueva version. */
|
|
1835
|
+
async duplicate(id, options) {
|
|
1836
|
+
validateRequired(id, "id");
|
|
1837
|
+
return this.client.post(
|
|
1838
|
+
`${this.basePath}/${id}/duplicate`,
|
|
1839
|
+
options ?? {}
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
/** Crear una nueva version DRAFT del presupuesto, incrementando el contador. */
|
|
1843
|
+
async createVersion(id) {
|
|
1844
|
+
validateRequired(id, "id");
|
|
1845
|
+
return this.client.post(`${this.basePath}/${id}/new-version`);
|
|
1846
|
+
}
|
|
1847
|
+
/** Historial de versiones y cambios. */
|
|
1848
|
+
async getHistory(id) {
|
|
1849
|
+
validateRequired(id, "id");
|
|
1850
|
+
return this.client.get(
|
|
1851
|
+
`${this.basePath}/${id}/history`
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
// ==========================================
|
|
1855
|
+
// Listado por entidad relacionada
|
|
1856
|
+
// ==========================================
|
|
1857
|
+
/** Presupuestos de un cliente. */
|
|
1858
|
+
async listByClient(clientId) {
|
|
1859
|
+
validateRequired(clientId, "clientId");
|
|
1860
|
+
return this.client.get(
|
|
1861
|
+
`${this.basePath}/by-entity/client/${clientId}`
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
/** Presupuestos de un proyecto. */
|
|
1865
|
+
async listByProject(projectId) {
|
|
1866
|
+
validateRequired(projectId, "projectId");
|
|
1867
|
+
return this.client.get(
|
|
1868
|
+
`${this.basePath}/by-entity/project/${projectId}`
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
/** Presupuestos de una oportunidad. */
|
|
1872
|
+
async listByOpportunity(opportunityId) {
|
|
1873
|
+
validateRequired(opportunityId, "opportunityId");
|
|
1874
|
+
return this.client.get(
|
|
1875
|
+
`${this.basePath}/by-entity/opportunity/${opportunityId}`
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
// ==========================================
|
|
1879
|
+
// Items
|
|
1880
|
+
// ==========================================
|
|
1881
|
+
/** Anyadir un item al presupuesto. */
|
|
1882
|
+
async addItem(budgetId, data) {
|
|
1883
|
+
validateRequired(budgetId, "budgetId");
|
|
1884
|
+
validateRequired(data.name, "name");
|
|
1885
|
+
return this.client.post(
|
|
1886
|
+
`${this.basePath}/${budgetId}/items`,
|
|
1887
|
+
data
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
/** Actualizar un item. */
|
|
1891
|
+
async updateItem(budgetId, itemId, data) {
|
|
1892
|
+
validateRequired(budgetId, "budgetId");
|
|
1893
|
+
validateRequired(itemId, "itemId");
|
|
1894
|
+
return this.client.patch(
|
|
1895
|
+
`${this.basePath}/${budgetId}/items/${itemId}`,
|
|
1896
|
+
data
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
/** Eliminar un item. */
|
|
1900
|
+
async removeItem(budgetId, itemId) {
|
|
1901
|
+
validateRequired(budgetId, "budgetId");
|
|
1902
|
+
validateRequired(itemId, "itemId");
|
|
1903
|
+
return this.client.delete(
|
|
1904
|
+
`${this.basePath}/${budgetId}/items/${itemId}`
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
/** Aplicar un nuevo orden a los items del presupuesto. */
|
|
1908
|
+
async reorderItems(budgetId, data) {
|
|
1909
|
+
validateRequired(budgetId, "budgetId");
|
|
1910
|
+
return this.client.patch(
|
|
1911
|
+
`${this.basePath}/${budgetId}/items/reorder`,
|
|
1912
|
+
data
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
// ==========================================
|
|
1916
|
+
// Secciones
|
|
1917
|
+
// ==========================================
|
|
1918
|
+
/** Listar secciones del presupuesto ordenadas por posicion y orden. */
|
|
1919
|
+
async listSections(budgetId) {
|
|
1920
|
+
validateRequired(budgetId, "budgetId");
|
|
1921
|
+
return this.client.get(
|
|
1922
|
+
`${this.basePath}/${budgetId}/sections`
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
/** Anyadir una seccion al presupuesto. */
|
|
1926
|
+
async addSection(budgetId, data) {
|
|
1927
|
+
validateRequired(budgetId, "budgetId");
|
|
1928
|
+
validateRequired(data.title, "title");
|
|
1929
|
+
return this.client.post(
|
|
1930
|
+
`${this.basePath}/${budgetId}/sections`,
|
|
1931
|
+
data
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
/** Actualizar una seccion existente. */
|
|
1935
|
+
async updateSection(budgetId, sectionId, data) {
|
|
1936
|
+
validateRequired(budgetId, "budgetId");
|
|
1937
|
+
validateRequired(sectionId, "sectionId");
|
|
1938
|
+
return this.client.patch(
|
|
1939
|
+
`${this.basePath}/${budgetId}/sections/${sectionId}`,
|
|
1940
|
+
data
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
/** Eliminar una seccion. */
|
|
1944
|
+
async removeSection(budgetId, sectionId) {
|
|
1945
|
+
validateRequired(budgetId, "budgetId");
|
|
1946
|
+
validateRequired(sectionId, "sectionId");
|
|
1947
|
+
return this.client.delete(
|
|
1948
|
+
`${this.basePath}/${budgetId}/sections/${sectionId}`
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
/** Reordenar secciones. */
|
|
1952
|
+
async reorderSections(budgetId, data) {
|
|
1953
|
+
validateRequired(budgetId, "budgetId");
|
|
1954
|
+
return this.client.patch(
|
|
1955
|
+
`${this.basePath}/${budgetId}/sections/reorder`,
|
|
1956
|
+
data
|
|
1957
|
+
);
|
|
1958
|
+
}
|
|
1959
|
+
// ==========================================
|
|
1960
|
+
// Acciones de flujo
|
|
1961
|
+
// ==========================================
|
|
1962
|
+
/** Enviar el presupuesto por correo al cliente. */
|
|
1963
|
+
async send(id, data) {
|
|
1964
|
+
validateRequired(id, "id");
|
|
1965
|
+
if (!Array.isArray(data.emails) || data.emails.length === 0) {
|
|
1966
|
+
throw new Error("Debes indicar al menos un email destinatario");
|
|
1967
|
+
}
|
|
1968
|
+
return this.client.post(`${this.basePath}/${id}/send`, data);
|
|
1969
|
+
}
|
|
1970
|
+
/** Aprobar el presupuesto. */
|
|
1971
|
+
async approve(id, data = {}) {
|
|
1972
|
+
validateRequired(id, "id");
|
|
1973
|
+
return this.client.post(`${this.basePath}/${id}/approve`, data);
|
|
1974
|
+
}
|
|
1975
|
+
/** Rechazar el presupuesto. El motivo (reason) es obligatorio. */
|
|
1976
|
+
async reject(id, data) {
|
|
1977
|
+
validateRequired(id, "id");
|
|
1978
|
+
validateRequired(data.reason, "reason");
|
|
1979
|
+
return this.client.post(`${this.basePath}/${id}/reject`, data);
|
|
1980
|
+
}
|
|
1981
|
+
/** Cancelar el presupuesto. */
|
|
1982
|
+
async cancel(id) {
|
|
1983
|
+
validateRequired(id, "id");
|
|
1984
|
+
return this.client.post(`${this.basePath}/${id}/cancel`);
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Cambiar el estado del presupuesto directamente (uso administrativo).
|
|
1988
|
+
* Restringido a SUPER_ADMIN en el backend.
|
|
1989
|
+
*/
|
|
1990
|
+
async changeStatus(id, status, reason) {
|
|
1991
|
+
validateRequired(id, "id");
|
|
1992
|
+
validateRequired(status, "status");
|
|
1993
|
+
const payload = { status, reason };
|
|
1994
|
+
return this.client.post(
|
|
1995
|
+
`${this.basePath}/${id}/change-status`,
|
|
1996
|
+
payload
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
/** Convertir un presupuesto aprobado en un proyecto CRM. */
|
|
2000
|
+
async convertToProject(id, options) {
|
|
2001
|
+
validateRequired(id, "id");
|
|
2002
|
+
validateRequired(options.name, "name");
|
|
2003
|
+
validateRequired(options.startDate, "startDate");
|
|
2004
|
+
return this.client.post(
|
|
2005
|
+
`${this.basePath}/${id}/convert-to-project`,
|
|
2006
|
+
options
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
/** Convertir un presupuesto aprobado en factura. */
|
|
2010
|
+
async convertToInvoice(id, options = {}) {
|
|
2011
|
+
validateRequired(id, "id");
|
|
2012
|
+
return this.client.post(
|
|
2013
|
+
`${this.basePath}/${id}/convert-to-invoice`,
|
|
2014
|
+
options
|
|
2015
|
+
);
|
|
2016
|
+
}
|
|
2017
|
+
// ==========================================
|
|
2018
|
+
// PDF
|
|
2019
|
+
// ==========================================
|
|
2020
|
+
/**
|
|
2021
|
+
* Generar el PDF del presupuesto para descarga.
|
|
2022
|
+
* Devuelve un Blob. Si el consumidor necesita una URL, usa `URL.createObjectURL`.
|
|
2023
|
+
*/
|
|
2024
|
+
async generatePdf(id) {
|
|
2025
|
+
validateRequired(id, "id");
|
|
2026
|
+
return this.fetchBinary(`${this.basePath}/${id}/generate-pdf`, "POST");
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Visualizar el PDF del presupuesto (Content-Disposition inline).
|
|
2030
|
+
* Devuelve un Blob.
|
|
2031
|
+
*/
|
|
2032
|
+
async viewPdf(id) {
|
|
2033
|
+
validateRequired(id, "id");
|
|
2034
|
+
return this.fetchBinary(`${this.basePath}/${id}/pdf`, "GET");
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Construir la URL del PDF del presupuesto (util para <iframe>, <embed>, etc.).
|
|
2038
|
+
* Incluye las credenciales en query string para funcionar en etiquetas HTML
|
|
2039
|
+
* que no pueden anyadir headers personalizados.
|
|
2040
|
+
*/
|
|
2041
|
+
getPdfUrl(id) {
|
|
2042
|
+
validateRequired(id, "id");
|
|
2043
|
+
return this.client.buildSseUrl(`${this.basePath}/${id}/pdf`);
|
|
2044
|
+
}
|
|
2045
|
+
// ==========================================
|
|
2046
|
+
// Adjuntos
|
|
2047
|
+
// ==========================================
|
|
2048
|
+
/** Listar adjuntos del presupuesto. */
|
|
2049
|
+
async getAttachments(budgetId) {
|
|
2050
|
+
validateRequired(budgetId, "budgetId");
|
|
2051
|
+
return this.client.get(
|
|
2052
|
+
`${this.basePath}/${budgetId}/attachments`
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Subir un adjunto al presupuesto.
|
|
2057
|
+
*
|
|
2058
|
+
* @param budgetId - UUID del presupuesto
|
|
2059
|
+
* @param file - Archivo a subir (File o Blob)
|
|
2060
|
+
* @param meta - Nombre visible y descripcion opcionales
|
|
2061
|
+
*/
|
|
2062
|
+
async uploadAttachment(budgetId, file, meta) {
|
|
2063
|
+
validateRequired(budgetId, "budgetId");
|
|
2064
|
+
const formData = new FormData();
|
|
2065
|
+
if (file instanceof Blob && !(file instanceof File)) {
|
|
2066
|
+
formData.append("file", file, meta?.name || "attachment");
|
|
2067
|
+
} else {
|
|
2068
|
+
formData.append("file", file);
|
|
2069
|
+
}
|
|
2070
|
+
if (meta?.name) formData.append("name", meta.name);
|
|
2071
|
+
if (meta?.description) formData.append("description", meta.description);
|
|
2072
|
+
return this.client.postForm(
|
|
2073
|
+
`${this.basePath}/${budgetId}/attachments`,
|
|
2074
|
+
formData
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
/** Eliminar un adjunto del presupuesto. */
|
|
2078
|
+
async deleteAttachment(budgetId, attachmentId) {
|
|
2079
|
+
validateRequired(budgetId, "budgetId");
|
|
2080
|
+
validateRequired(attachmentId, "attachmentId");
|
|
2081
|
+
return this.client.delete(
|
|
2082
|
+
`${this.basePath}/${budgetId}/attachments/${attachmentId}`
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Subir una imagen para el editor WYSIWYG.
|
|
2087
|
+
* La imagen no aparece como adjunto en el File Manager: solo devuelve la URL
|
|
2088
|
+
* para incrustarla en el contenido del presupuesto.
|
|
2089
|
+
*/
|
|
2090
|
+
async uploadEditorImage(budgetId, file) {
|
|
2091
|
+
validateRequired(budgetId, "budgetId");
|
|
2092
|
+
const formData = new FormData();
|
|
2093
|
+
if (file instanceof Blob && !(file instanceof File)) {
|
|
2094
|
+
formData.append("file", file, "image");
|
|
2095
|
+
} else {
|
|
2096
|
+
formData.append("file", file);
|
|
2097
|
+
}
|
|
2098
|
+
return this.client.postForm(
|
|
2099
|
+
`${this.basePath}/${budgetId}/editor-images`,
|
|
2100
|
+
formData
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
// ==========================================
|
|
2104
|
+
// Utilidades: codigo, stats, export, masivos
|
|
2105
|
+
// ==========================================
|
|
2106
|
+
/** Proximo codigo sugerido segun la configuracion de numeracion. */
|
|
2107
|
+
async getNextCode() {
|
|
2108
|
+
return this.client.get(`${this.basePath}/next-code`);
|
|
2109
|
+
}
|
|
2110
|
+
/** Comprobar si un codigo ya esta en uso. */
|
|
2111
|
+
async checkCode(code, excludeId) {
|
|
2112
|
+
validateRequired(code, "code");
|
|
2113
|
+
return this.client.get(
|
|
2114
|
+
`${this.basePath}/check-code/${encodeURIComponent(code)}`,
|
|
2115
|
+
{ params: excludeId ? { excludeId } : void 0 }
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
/** Estadisticas agregadas (totales, tasa aprobacion, etc.). */
|
|
2119
|
+
async getStats(filters) {
|
|
2120
|
+
const params = this.serializeFilters(filters);
|
|
2121
|
+
return this.client.get(`${this.basePath}/stats`, {
|
|
2122
|
+
params
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
/** Ejecutar una operacion masiva (delete / cancel / change-status). */
|
|
2126
|
+
async bulk(operation, ids, options) {
|
|
2127
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
2128
|
+
throw new Error("Debes indicar al menos un id de presupuesto");
|
|
2129
|
+
}
|
|
2130
|
+
const payload = { operation, ids, options };
|
|
2131
|
+
return this.client.post(`${this.basePath}/bulk`, payload);
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Exportar el listado filtrado en el formato indicado.
|
|
2135
|
+
* De momento solo se soporta `csv`. Devuelve un Blob.
|
|
2136
|
+
*/
|
|
2137
|
+
async export(filters, format = "csv") {
|
|
2138
|
+
if (format !== "csv") {
|
|
2139
|
+
throw new Error(`Formato de exportacion no soportado: ${format}`);
|
|
2140
|
+
}
|
|
2141
|
+
const params = this.serializeFilters(filters);
|
|
2142
|
+
return this.fetchBinary(`${this.basePath}/export`, "GET", params);
|
|
2143
|
+
}
|
|
2144
|
+
// ==========================================
|
|
2145
|
+
// IA
|
|
2146
|
+
// ==========================================
|
|
2147
|
+
/**
|
|
2148
|
+
* Encolar un job asincrono para generar items del presupuesto a partir
|
|
2149
|
+
* de un texto en lenguaje natural. Devuelve el `jobId` para seguir el
|
|
2150
|
+
* progreso vía `streamGenerateItemsStatus`.
|
|
2151
|
+
*/
|
|
2152
|
+
async generateItemsWithAi(data) {
|
|
2153
|
+
validateRequired(data.text, "text");
|
|
2154
|
+
validateStringLength(data.text, "text", 10);
|
|
2155
|
+
return this.client.post(
|
|
2156
|
+
`${this.basePath}/generate-items-ai`,
|
|
2157
|
+
data
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Suscribirse al progreso (SSE) del job de generacion de items con IA.
|
|
2162
|
+
* Requiere EventSource en el entorno.
|
|
2163
|
+
*/
|
|
2164
|
+
streamGenerateItemsStatus(jobId, options) {
|
|
2165
|
+
validateRequired(jobId, "jobId");
|
|
2166
|
+
if (typeof EventSource === "undefined") {
|
|
2167
|
+
throw new Error(
|
|
2168
|
+
"EventSource no esta disponible en este entorno. Usa un polyfill en Node."
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
const sseUrl = this.client.buildSseUrl(
|
|
2172
|
+
`${this.basePath}/generate-items-ai/${jobId}/status`
|
|
2173
|
+
);
|
|
2174
|
+
const es = new EventSource(sseUrl);
|
|
2175
|
+
es.addEventListener("progress", (event) => {
|
|
2176
|
+
try {
|
|
2177
|
+
const data = JSON.parse(event.data);
|
|
2178
|
+
options?.onProgress?.(data);
|
|
2179
|
+
} catch {
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
es.addEventListener("complete", (event) => {
|
|
2183
|
+
try {
|
|
2184
|
+
const data = JSON.parse(event.data);
|
|
2185
|
+
options?.onComplete?.(data);
|
|
2186
|
+
} catch {
|
|
2187
|
+
} finally {
|
|
2188
|
+
es.close();
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
es.addEventListener("error", (event) => {
|
|
2192
|
+
try {
|
|
2193
|
+
const data = event.data ? JSON.parse(event.data) : null;
|
|
2194
|
+
options?.onError?.(
|
|
2195
|
+
new Error(data?.error || "Error en el job de generacion de items")
|
|
2196
|
+
);
|
|
2197
|
+
} catch {
|
|
2198
|
+
options?.onError?.(new Error("Error en la conexion SSE"));
|
|
2199
|
+
} finally {
|
|
2200
|
+
es.close();
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
return { close: () => es.close() };
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Generar un presupuesto completo (secciones + items) a partir de un texto.
|
|
2207
|
+
* Devuelve la estructura sugerida sin crear el presupuesto.
|
|
2208
|
+
*/
|
|
2209
|
+
async generateCompleteWithAi(data) {
|
|
2210
|
+
validateRequired(data.text, "text");
|
|
2211
|
+
validateStringLength(data.text, "text", 10);
|
|
2212
|
+
return this.client.post(
|
|
2213
|
+
`${this.basePath}/generate-complete-ai`,
|
|
2214
|
+
data
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
/** Generar unicamente secciones sugeridas para un presupuesto existente. */
|
|
2218
|
+
async generateSectionsWithAi(data) {
|
|
2219
|
+
validateRequired(data.text, "text");
|
|
2220
|
+
validateStringLength(data.text, "text", 10);
|
|
2221
|
+
return this.client.post(
|
|
2222
|
+
`${this.basePath}/generate-sections-ai`,
|
|
2223
|
+
data
|
|
2224
|
+
);
|
|
2225
|
+
}
|
|
2226
|
+
// ==========================================
|
|
2227
|
+
// Helpers privados
|
|
2228
|
+
// ==========================================
|
|
2229
|
+
/**
|
|
2230
|
+
* Convierte los filtros a un objeto plano apto para query string.
|
|
2231
|
+
* Aplana arrays en formato coma-separado (patron del backend).
|
|
2232
|
+
*/
|
|
2233
|
+
serializeFilters(filters) {
|
|
2234
|
+
if (!filters) return void 0;
|
|
2235
|
+
const out = {};
|
|
2236
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
2237
|
+
if (value === void 0 || value === null) continue;
|
|
2238
|
+
if (Array.isArray(value)) {
|
|
2239
|
+
out[key] = value.join(",");
|
|
2240
|
+
} else {
|
|
2241
|
+
out[key] = value;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
return out;
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Descargar una respuesta binaria (PDF, CSV) respetando el auth del cliente.
|
|
2248
|
+
* Usa `buildSseUrl` para añadir las credenciales en la URL cuando la ruta
|
|
2249
|
+
* no pueda llevarlas en headers (p. ej. etiquetas `<a href>`).
|
|
2250
|
+
*/
|
|
2251
|
+
async fetchBinary(path, method, params) {
|
|
2252
|
+
if (method === "GET") {
|
|
2253
|
+
const response2 = await this.client.get(path, {
|
|
2254
|
+
params,
|
|
2255
|
+
responseType: "arraybuffer"
|
|
2256
|
+
});
|
|
2257
|
+
return new Blob([response2]);
|
|
2258
|
+
}
|
|
2259
|
+
const response = await this.client.post(path, void 0, {
|
|
2260
|
+
params,
|
|
2261
|
+
responseType: "arraybuffer"
|
|
2262
|
+
});
|
|
2263
|
+
return new Blob([response]);
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
|
|
2267
|
+
// src/services/budget-templates.service.ts
|
|
2268
|
+
var BudgetTemplatesService = class {
|
|
2269
|
+
constructor(client) {
|
|
2270
|
+
this.basePath = "/crm/budget-templates";
|
|
2271
|
+
this.budgetsPath = "/crm/budgets";
|
|
2272
|
+
this.client = client;
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Listar plantillas activas.
|
|
2276
|
+
* Las plantillas del sistema aparecen primero, seguidas de las
|
|
2277
|
+
* personalizadas ordenadas por fecha de creacion descendente.
|
|
2278
|
+
*/
|
|
2279
|
+
async list() {
|
|
2280
|
+
return this.client.get(this.basePath);
|
|
2281
|
+
}
|
|
2282
|
+
/** Obtener el detalle de una plantilla por UUID. */
|
|
2283
|
+
async get(id) {
|
|
2284
|
+
validateRequired(id, "id");
|
|
2285
|
+
return this.client.get(`${this.basePath}/${id}`);
|
|
2286
|
+
}
|
|
2287
|
+
/** Crear una plantilla personalizada. */
|
|
2288
|
+
async create(data) {
|
|
2289
|
+
validateRequired(data.name, "name");
|
|
2290
|
+
validateStringLength(data.name, "name", 1, 100);
|
|
2291
|
+
return this.client.post(this.basePath, data);
|
|
2292
|
+
}
|
|
2293
|
+
/** Actualizar una plantilla personalizada (las del sistema son inmutables). */
|
|
2294
|
+
async update(id, data) {
|
|
2295
|
+
validateRequired(id, "id");
|
|
2296
|
+
return this.client.patch(`${this.basePath}/${id}`, data);
|
|
2297
|
+
}
|
|
2298
|
+
/** Archivar (soft delete) una plantilla personalizada. */
|
|
2299
|
+
async delete(id) {
|
|
2300
|
+
validateRequired(id, "id");
|
|
2301
|
+
return this.client.delete(`${this.basePath}/${id}`);
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Crear una plantilla personalizada copiando items y secciones de un
|
|
2305
|
+
* presupuesto existente.
|
|
2306
|
+
*/
|
|
2307
|
+
async createFromBudget(budgetId, data) {
|
|
2308
|
+
validateRequired(budgetId, "budgetId");
|
|
2309
|
+
validateRequired(data.name, "name");
|
|
2310
|
+
return this.client.post(
|
|
2311
|
+
`${this.basePath}/from-budget/${budgetId}`,
|
|
2312
|
+
data
|
|
2313
|
+
);
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Crear un presupuesto DRAFT aplicando una plantilla.
|
|
2317
|
+
* Copia los items/secciones de la plantilla y asigna cliente/oportunidad.
|
|
2318
|
+
*/
|
|
2319
|
+
async applyTemplate(templateId, data) {
|
|
2320
|
+
validateRequired(templateId, "templateId");
|
|
2321
|
+
validateRequired(data.clientId, "clientId");
|
|
2322
|
+
return this.client.post(
|
|
2323
|
+
`${this.budgetsPath}/from-template/${templateId}`,
|
|
2324
|
+
data
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
};
|
|
2328
|
+
|
|
2329
|
+
// src/services/budget-comments.service.ts
|
|
2330
|
+
var BudgetCommentsService = class {
|
|
2331
|
+
constructor(client) {
|
|
2332
|
+
this.basePath = "/crm/budgets";
|
|
2333
|
+
this.client = client;
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Listar comentarios del presupuesto.
|
|
2337
|
+
*
|
|
2338
|
+
* @param budgetId - UUID del presupuesto.
|
|
2339
|
+
* @param filter - Filtros de visibilidad, paginación y rango de fechas.
|
|
2340
|
+
*
|
|
2341
|
+
* @example
|
|
2342
|
+
* ```typescript
|
|
2343
|
+
* const page = await sdk.budgetComments.list(budgetId, { visibility: 'CLIENT', page: 1, limit: 20 });
|
|
2344
|
+
* page.data.forEach(c => console.log(c.body));
|
|
2345
|
+
* ```
|
|
2346
|
+
*/
|
|
2347
|
+
async list(budgetId, filter) {
|
|
2348
|
+
validateRequired(budgetId, "budgetId");
|
|
2349
|
+
const params = this.serializeListFilter(filter);
|
|
2350
|
+
const raw = await this.client.get(
|
|
2351
|
+
`${this.basePath}/${budgetId}/comments`,
|
|
2352
|
+
{ params }
|
|
2353
|
+
);
|
|
2354
|
+
const data = raw.data ?? raw.items ?? [];
|
|
2355
|
+
const pageInfo = {
|
|
2356
|
+
...raw.pagination ?? {},
|
|
2357
|
+
...raw.pageInfo ?? {}
|
|
2358
|
+
};
|
|
2359
|
+
return { data, pageInfo };
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* Obtener un comentario por su ID.
|
|
2363
|
+
*
|
|
2364
|
+
* Nota: el backend actual no expone `GET /comments/:commentId` a nivel de
|
|
2365
|
+
* REST. El SDK lo emula filtrando localmente el resultado del listado. Si
|
|
2366
|
+
* en el futuro se añade un endpoint dedicado, este método se puede migrar
|
|
2367
|
+
* sin romper la firma.
|
|
2368
|
+
*/
|
|
2369
|
+
async get(budgetId, commentId) {
|
|
2370
|
+
validateRequired(budgetId, "budgetId");
|
|
2371
|
+
validateRequired(commentId, "commentId");
|
|
2372
|
+
const page = await this.list(budgetId, { limit: 100 });
|
|
2373
|
+
const found = page.data.find((c) => c.id === commentId);
|
|
2374
|
+
if (!found) {
|
|
2375
|
+
throw new Error(
|
|
2376
|
+
`Comentario ${commentId} no encontrado en el presupuesto ${budgetId}`
|
|
2377
|
+
);
|
|
2378
|
+
}
|
|
2379
|
+
return found;
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* Crear un comentario en el presupuesto.
|
|
2383
|
+
*
|
|
2384
|
+
* La visibilidad (INTERNAL / CLIENT) debe ser compatible con los permisos
|
|
2385
|
+
* del usuario autenticado. Las menciones solo tienen efecto en comentarios
|
|
2386
|
+
* internos.
|
|
2387
|
+
*/
|
|
2388
|
+
async create(budgetId, input) {
|
|
2389
|
+
validateRequired(budgetId, "budgetId");
|
|
2390
|
+
validateRequired(input.body, "body");
|
|
2391
|
+
validateStringLength(input.body, "body", 1, 5e3);
|
|
2392
|
+
return this.client.post(
|
|
2393
|
+
`${this.basePath}/${budgetId}/comments`,
|
|
2394
|
+
input
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
/** Actualizar el cuerpo de un comentario. Solo autor o SUPER_ADMIN. */
|
|
2398
|
+
async update(budgetId, commentId, input) {
|
|
2399
|
+
validateRequired(budgetId, "budgetId");
|
|
2400
|
+
validateRequired(commentId, "commentId");
|
|
2401
|
+
validateRequired(input.body, "body");
|
|
2402
|
+
validateStringLength(input.body, "body", 1, 5e3);
|
|
2403
|
+
return this.client.patch(
|
|
2404
|
+
`${this.basePath}/${budgetId}/comments/${commentId}`,
|
|
2405
|
+
input
|
|
2406
|
+
);
|
|
2407
|
+
}
|
|
2408
|
+
/** Eliminar un comentario. Solo autor o SUPER_ADMIN. */
|
|
2409
|
+
async remove(budgetId, commentId) {
|
|
2410
|
+
validateRequired(budgetId, "budgetId");
|
|
2411
|
+
validateRequired(commentId, "commentId");
|
|
2412
|
+
return this.client.delete(
|
|
2413
|
+
`${this.basePath}/${budgetId}/comments/${commentId}`
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Creación masiva tolerante a fallos (saga).
|
|
2418
|
+
*
|
|
2419
|
+
* Nota: a día de hoy, el backend expone el bulk únicamente a través de la
|
|
2420
|
+
* herramienta MCP `crm_budget_comments.bulk_create`. Para consumirlo desde
|
|
2421
|
+
* REST, el SDK llama secuencialmente a `create` y agrega el resultado,
|
|
2422
|
+
* respetando el contrato del método. Sustituir cuando exista un endpoint
|
|
2423
|
+
* REST dedicado.
|
|
2424
|
+
*/
|
|
2425
|
+
async bulkCreate(budgetId, items, options) {
|
|
2426
|
+
validateRequired(budgetId, "budgetId");
|
|
2427
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
2428
|
+
throw new Error("Debes indicar al menos un comentario en items");
|
|
2429
|
+
}
|
|
2430
|
+
if (items.length > 50) {
|
|
2431
|
+
throw new Error("No se pueden crear m\xE1s de 50 comentarios por lote");
|
|
2432
|
+
}
|
|
2433
|
+
if (options?.idempotencyKey) {
|
|
2434
|
+
try {
|
|
2435
|
+
const raw = await this.client.post(
|
|
2436
|
+
`${this.basePath}/${budgetId}/comments:bulk`,
|
|
2437
|
+
{ items },
|
|
2438
|
+
{ headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
2439
|
+
);
|
|
2440
|
+
return this.normalizeBulkResult(raw);
|
|
2441
|
+
} catch {
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
const succeeded = [];
|
|
2445
|
+
const failed = [];
|
|
2446
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
2447
|
+
const dto = items[index];
|
|
2448
|
+
try {
|
|
2449
|
+
const created = await this.create(budgetId, dto);
|
|
2450
|
+
succeeded.push(created);
|
|
2451
|
+
} catch (error) {
|
|
2452
|
+
const message = error instanceof Error ? error.message : "Error desconocido";
|
|
2453
|
+
failed.push({
|
|
2454
|
+
index,
|
|
2455
|
+
clientOperationId: dto.clientOperationId,
|
|
2456
|
+
code: this.mapErrorCode(error),
|
|
2457
|
+
message
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
return {
|
|
2462
|
+
succeeded,
|
|
2463
|
+
failed,
|
|
2464
|
+
summary: {
|
|
2465
|
+
total: items.length,
|
|
2466
|
+
ok: succeeded.length,
|
|
2467
|
+
failed: failed.length
|
|
2468
|
+
}
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Devuelve el timeline unificado: comentarios + eventos de historial del
|
|
2473
|
+
* presupuesto ordenados cronológicamente descendente.
|
|
2474
|
+
*
|
|
2475
|
+
* Nota: el backend expone esta vista agregada a través de la herramienta MCP
|
|
2476
|
+
* `crm_budget_comments.get_timeline`. Hasta que se habilite un endpoint REST
|
|
2477
|
+
* dedicado, el SDK devuelve los comentarios paginados y un `items` sólo con
|
|
2478
|
+
* kind `COMMENT`. Se marca como follow-up.
|
|
2479
|
+
*/
|
|
2480
|
+
async getTimeline(budgetId, filter) {
|
|
2481
|
+
validateRequired(budgetId, "budgetId");
|
|
2482
|
+
try {
|
|
2483
|
+
const params = this.serializeTimelineFilter(filter);
|
|
2484
|
+
const raw = await this.client.get(
|
|
2485
|
+
`${this.basePath}/${budgetId}/timeline`,
|
|
2486
|
+
{ params }
|
|
2487
|
+
);
|
|
2488
|
+
return raw;
|
|
2489
|
+
} catch {
|
|
2490
|
+
const page = await this.list(budgetId, {
|
|
2491
|
+
limit: filter?.limit ?? 50,
|
|
2492
|
+
since: filter?.since,
|
|
2493
|
+
until: filter?.until
|
|
2494
|
+
});
|
|
2495
|
+
return {
|
|
2496
|
+
items: page.data.map((comment) => ({
|
|
2497
|
+
kind: "COMMENT",
|
|
2498
|
+
at: comment.createdAt,
|
|
2499
|
+
comment
|
|
2500
|
+
})),
|
|
2501
|
+
pageInfo: {
|
|
2502
|
+
nextCursor: page.pageInfo?.nextCursor ?? null,
|
|
2503
|
+
hasMore: page.pageInfo?.hasMore ?? false,
|
|
2504
|
+
pageSize: page.data.length
|
|
2505
|
+
}
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
// ==========================================
|
|
2510
|
+
// Helpers privados
|
|
2511
|
+
// ==========================================
|
|
2512
|
+
serializeListFilter(filter) {
|
|
2513
|
+
if (!filter) return void 0;
|
|
2514
|
+
const out = {};
|
|
2515
|
+
if (filter.visibility && filter.visibility !== "ALL") {
|
|
2516
|
+
out.visibility = filter.visibility;
|
|
2517
|
+
}
|
|
2518
|
+
if (filter.authorId) out.authorId = filter.authorId;
|
|
2519
|
+
if (filter.mentionedUserId) out.mentionedUserId = filter.mentionedUserId;
|
|
2520
|
+
if (filter.cursor) out.cursor = filter.cursor;
|
|
2521
|
+
if (typeof filter.limit === "number") out.limit = filter.limit;
|
|
2522
|
+
if (typeof filter.page === "number") out.page = filter.page;
|
|
2523
|
+
if (filter.since) out.since = filter.since;
|
|
2524
|
+
if (filter.until) out.until = filter.until;
|
|
2525
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
2526
|
+
}
|
|
2527
|
+
serializeTimelineFilter(filter) {
|
|
2528
|
+
if (!filter) return void 0;
|
|
2529
|
+
const out = {};
|
|
2530
|
+
if (filter.cursor) out.cursor = filter.cursor;
|
|
2531
|
+
if (typeof filter.limit === "number") out.limit = filter.limit;
|
|
2532
|
+
if (filter.since) out.since = filter.since;
|
|
2533
|
+
if (filter.until) out.until = filter.until;
|
|
2534
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
2535
|
+
}
|
|
2536
|
+
normalizeBulkResult(raw) {
|
|
2537
|
+
const total = raw.summary?.total ?? raw.succeeded.length + raw.failed.length;
|
|
2538
|
+
const ok = raw.summary?.ok ?? raw.succeeded.length;
|
|
2539
|
+
const failed = raw.summary?.failed ?? raw.failed.length;
|
|
2540
|
+
return {
|
|
2541
|
+
succeeded: raw.succeeded,
|
|
2542
|
+
failed: raw.failed,
|
|
2543
|
+
summary: { total, ok, failed }
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
mapErrorCode(error) {
|
|
2547
|
+
if (!error || typeof error !== "object") return "INTERNAL";
|
|
2548
|
+
const code = error.code;
|
|
2549
|
+
if (typeof code === "string") return code;
|
|
2550
|
+
const statusCode = error.statusCode;
|
|
2551
|
+
if (statusCode === 404) return "NOT_FOUND";
|
|
2552
|
+
if (statusCode === 403) return "FORBIDDEN";
|
|
2553
|
+
if (statusCode === 400) return "VALIDATION";
|
|
2554
|
+
if (statusCode === 409) return "CONFLICT";
|
|
2555
|
+
if (statusCode === 429) return "RATE_LIMITED";
|
|
2556
|
+
return "INTERNAL";
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
|
|
2560
|
+
// src/services/budget-signatures.service.ts
|
|
2561
|
+
var BudgetSignaturesService = class {
|
|
2562
|
+
constructor(client) {
|
|
2563
|
+
this.basePath = "/crm/budgets";
|
|
2564
|
+
this.client = client;
|
|
2565
|
+
}
|
|
2566
|
+
// ==========================================
|
|
2567
|
+
// Firmas registradas
|
|
2568
|
+
// ==========================================
|
|
2569
|
+
/** Listar todas las firmas asociadas al presupuesto. */
|
|
2570
|
+
async list(budgetId) {
|
|
2571
|
+
validateRequired(budgetId, "budgetId");
|
|
2572
|
+
const raw = await this.client.get(
|
|
2573
|
+
`${this.basePath}/${budgetId}/signatures`
|
|
2574
|
+
);
|
|
2575
|
+
return raw.items ?? [];
|
|
2576
|
+
}
|
|
2577
|
+
/** Obtener el detalle de una firma concreta. */
|
|
2578
|
+
async get(budgetId, signatureId) {
|
|
2579
|
+
validateRequired(budgetId, "budgetId");
|
|
2580
|
+
validateRequired(signatureId, "signatureId");
|
|
2581
|
+
return this.client.get(
|
|
2582
|
+
`${this.basePath}/${budgetId}/signatures/${signatureId}`
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* Verificar la integridad de una firma. Recalcula el hash del documento con
|
|
2587
|
+
* la versión (`hashVersion`) con la que se firmó originalmente y lo compara.
|
|
2588
|
+
*
|
|
2589
|
+
* Si `valid === false`, el documento ha sido modificado después de firmar.
|
|
2590
|
+
*/
|
|
2591
|
+
async verify(budgetId, signatureId) {
|
|
2592
|
+
validateRequired(budgetId, "budgetId");
|
|
2593
|
+
validateRequired(signatureId, "signatureId");
|
|
2594
|
+
return this.client.get(
|
|
2595
|
+
`${this.basePath}/${budgetId}/signatures/${signatureId}/verify`
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Descargar el certificado legal de una firma como Blob.
|
|
2600
|
+
*
|
|
2601
|
+
* Ideal para consumidores que necesiten guardar el PDF localmente o abrirlo
|
|
2602
|
+
* con `URL.createObjectURL`.
|
|
2603
|
+
*/
|
|
2604
|
+
async downloadCertificate(budgetId, signatureId) {
|
|
2605
|
+
validateRequired(budgetId, "budgetId");
|
|
2606
|
+
validateRequired(signatureId, "signatureId");
|
|
2607
|
+
const response = await this.client.get(
|
|
2608
|
+
`${this.basePath}/${budgetId}/signatures/${signatureId}/certificate.pdf`,
|
|
2609
|
+
{ responseType: "arraybuffer" }
|
|
2610
|
+
);
|
|
2611
|
+
return new Blob([response], { type: "application/pdf" });
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Construir la URL del certificado (útil para `<iframe>` o enlaces directos).
|
|
2615
|
+
* Incluye las credenciales en query string cuando el SDK usa API key.
|
|
2616
|
+
*/
|
|
2617
|
+
getCertificateUrl(budgetId, signatureId) {
|
|
2618
|
+
validateRequired(budgetId, "budgetId");
|
|
2619
|
+
validateRequired(signatureId, "signatureId");
|
|
2620
|
+
return this.client.buildSseUrl(
|
|
2621
|
+
`${this.basePath}/${budgetId}/signatures/${signatureId}/certificate.pdf`
|
|
2622
|
+
);
|
|
2623
|
+
}
|
|
2624
|
+
// ==========================================
|
|
2625
|
+
// Magic links
|
|
2626
|
+
// ==========================================
|
|
2627
|
+
/**
|
|
2628
|
+
* Generar un magic link de firma. El endpoint es idempotente para la tupla
|
|
2629
|
+
* (budgetId, signerEmail): si ya existe un token vivo se devuelve con
|
|
2630
|
+
* `reused: true` y `signingUrl: null` (el raw token original no se
|
|
2631
|
+
* recupera).
|
|
2632
|
+
*/
|
|
2633
|
+
async generateSigningLink(budgetId, input) {
|
|
2634
|
+
validateRequired(budgetId, "budgetId");
|
|
2635
|
+
validateRequired(input.signerEmail, "signerEmail");
|
|
2636
|
+
return this.client.post(
|
|
2637
|
+
`${this.basePath}/${budgetId}/signing-link`,
|
|
2638
|
+
input
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Listar los magic links vivos del presupuesto (no usados, no revocados y no
|
|
2643
|
+
* expirados). Los filtros `includeRevoked`/`includeExpired` se reservan para
|
|
2644
|
+
* cuando el backend exponga la vista extendida.
|
|
2645
|
+
*/
|
|
2646
|
+
async listSigningLinks(budgetId, filter) {
|
|
2647
|
+
validateRequired(budgetId, "budgetId");
|
|
2648
|
+
const params = this.serializeSigningLinksFilter(filter);
|
|
2649
|
+
const raw = await this.client.get(
|
|
2650
|
+
`${this.basePath}/${budgetId}/signing-links`,
|
|
2651
|
+
{ params }
|
|
2652
|
+
);
|
|
2653
|
+
return raw.items ?? [];
|
|
2654
|
+
}
|
|
2655
|
+
/** Revocar un magic link. Idempotente: llamarla dos veces no falla. */
|
|
2656
|
+
async revokeSigningLink(budgetId, tokenId) {
|
|
2657
|
+
validateRequired(budgetId, "budgetId");
|
|
2658
|
+
validateRequired(tokenId, "tokenId");
|
|
2659
|
+
return this.client.delete(
|
|
2660
|
+
`${this.basePath}/${budgetId}/signing-links/${tokenId}`
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Audit trail paginado por cursor de los eventos de firma del presupuesto
|
|
2665
|
+
* (emisión, visualización, descarga de PDF, aprobación, rechazo,
|
|
2666
|
+
* expiración, revocación, fallos).
|
|
2667
|
+
*/
|
|
2668
|
+
async getAuditTrail(budgetId, filter) {
|
|
2669
|
+
validateRequired(budgetId, "budgetId");
|
|
2670
|
+
const params = this.serializeAuditFilter(filter);
|
|
2671
|
+
const raw = await this.client.get(
|
|
2672
|
+
`${this.basePath}/${budgetId}/signing-links/audit`,
|
|
2673
|
+
{ params }
|
|
2674
|
+
);
|
|
2675
|
+
const items = raw.items ?? [];
|
|
2676
|
+
if (filter?.tokenId) {
|
|
2677
|
+
return items.filter((e) => e.tokenId === filter.tokenId);
|
|
2678
|
+
}
|
|
2679
|
+
return items;
|
|
2680
|
+
}
|
|
2681
|
+
// ==========================================
|
|
2682
|
+
// Helpers privados
|
|
2683
|
+
// ==========================================
|
|
2684
|
+
serializeSigningLinksFilter(filter) {
|
|
2685
|
+
if (!filter) return void 0;
|
|
2686
|
+
const out = {};
|
|
2687
|
+
if (typeof filter.includeRevoked === "boolean") {
|
|
2688
|
+
out.includeRevoked = filter.includeRevoked;
|
|
2689
|
+
}
|
|
2690
|
+
if (typeof filter.includeExpired === "boolean") {
|
|
2691
|
+
out.includeExpired = filter.includeExpired;
|
|
2692
|
+
}
|
|
2693
|
+
if (filter.cursor) out.cursor = filter.cursor;
|
|
2694
|
+
if (typeof filter.limit === "number") out.limit = filter.limit;
|
|
2695
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
2696
|
+
}
|
|
2697
|
+
serializeAuditFilter(filter) {
|
|
2698
|
+
if (!filter) return void 0;
|
|
2699
|
+
const out = {};
|
|
2700
|
+
if (filter.cursor) out.cursor = filter.cursor;
|
|
2701
|
+
if (typeof filter.limit === "number") out.limit = filter.limit;
|
|
2702
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
|
|
2706
|
+
// src/services/organization-settings.service.ts
|
|
2707
|
+
var OrganizationSettingsService = class {
|
|
2708
|
+
constructor(client) {
|
|
2709
|
+
this.basePath = "/organization-settings";
|
|
2710
|
+
this.client = client;
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Obtener los datos públicos de la organización.
|
|
2714
|
+
*
|
|
2715
|
+
* Incluye identidad corporativa, branding, configuración de facturación
|
|
2716
|
+
* (moneda, impuesto por defecto, país) y canales de contacto.
|
|
2717
|
+
*
|
|
2718
|
+
* @returns Configuración pública de la organización.
|
|
2719
|
+
*
|
|
2720
|
+
* @example
|
|
2721
|
+
* ```typescript
|
|
2722
|
+
* const settings = await sdk.organizationSettings.getPublicSettings();
|
|
2723
|
+
* console.log(settings.defaultCurrency); // "EUR"
|
|
2724
|
+
* console.log(settings.defaultTaxName); // "IVA"
|
|
2725
|
+
* console.log(settings.defaultTaxRate); // 21
|
|
2726
|
+
* console.log(settings.country); // "ES"
|
|
2727
|
+
* ```
|
|
2728
|
+
*/
|
|
2729
|
+
async getPublicSettings() {
|
|
2730
|
+
return this.client.get(`${this.basePath}/public`);
|
|
2731
|
+
}
|
|
2732
|
+
};
|
|
2733
|
+
|
|
2734
|
+
// src/types/budget.types.ts
|
|
2735
|
+
var BUDGET_WEBHOOK_EVENTS = [
|
|
2736
|
+
"BUDGET_CREATED",
|
|
2737
|
+
"BUDGET_UPDATED",
|
|
2738
|
+
"BUDGET_SENT",
|
|
2739
|
+
"BUDGET_APPROVED",
|
|
2740
|
+
"BUDGET_REJECTED",
|
|
2741
|
+
"BUDGET_CANCELLED",
|
|
2742
|
+
"BUDGET_EXPIRED",
|
|
2743
|
+
"BUDGET_DUPLICATED",
|
|
2744
|
+
"BUDGET_CONVERTED_TO_PROJECT",
|
|
2745
|
+
"BUDGET_CONVERTED_TO_INVOICE"
|
|
2746
|
+
];
|
|
2747
|
+
|
|
1670
2748
|
// src/index.ts
|
|
1671
2749
|
var UtiliaSDK = class {
|
|
1672
2750
|
/**
|
|
@@ -1706,6 +2784,11 @@ var UtiliaSDK = class {
|
|
|
1706
2784
|
this.tickets = new TicketsService(this._client);
|
|
1707
2785
|
this.users = new UsersService(this._client);
|
|
1708
2786
|
this.ai = new AiService(this._client);
|
|
2787
|
+
this.budgets = new BudgetsService(this._client);
|
|
2788
|
+
this.budgetTemplates = new BudgetTemplatesService(this._client);
|
|
2789
|
+
this.budgetComments = new BudgetCommentsService(this._client);
|
|
2790
|
+
this.budgetSignatures = new BudgetSignaturesService(this._client);
|
|
2791
|
+
this.organizationSettings = new OrganizationSettingsService(this._client);
|
|
1709
2792
|
}
|
|
1710
2793
|
/**
|
|
1711
2794
|
* Servicio OAuth (solo disponible si se configuro oauth en el constructor)
|
|
@@ -1715,6 +2798,7 @@ var UtiliaSDK = class {
|
|
|
1715
2798
|
}
|
|
1716
2799
|
};
|
|
1717
2800
|
export {
|
|
2801
|
+
BUDGET_WEBHOOK_EVENTS,
|
|
1718
2802
|
ErrorCode,
|
|
1719
2803
|
FileTokenStorage,
|
|
1720
2804
|
MemoryTokenStorage,
|