@markwharton/liquidplanner 1.11.0 → 2.0.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 +65 -1
- package/dist/client.d.ts +74 -45
- package/dist/client.js +285 -105
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -6
- package/dist/index.d.ts +9 -5
- package/dist/index.js +9 -4
- package/dist/tree.d.ts +32 -0
- package/dist/tree.js +86 -0
- package/dist/types.d.ts +89 -38
- package/dist/utils.d.ts +31 -5
- package/dist/utils.js +56 -5
- package/dist/workflows.js +8 -6
- package/package.json +2 -2
- package/dist/cache.d.ts +0 -43
- package/dist/cache.js +0 -78
package/dist/client.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @see https://api-docs.liquidplanner.com/
|
|
8
8
|
*/
|
|
9
|
-
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
9
|
+
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIsNot, filterIn, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
|
|
10
|
+
import { buildTree, getTreeAncestors } from './tree.js';
|
|
10
11
|
import { parseLPErrorResponse } from './errors.js';
|
|
11
12
|
import { LP_API_BASE } from './constants.js';
|
|
12
|
-
import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
|
|
13
|
+
import { TTLCache, batchMap, getErrorMessage, fetchWithRetry, ok, err } from '@markwharton/api-core';
|
|
13
14
|
/** Transform raw API item to LPItem, preserving scheduling and effort fields */
|
|
14
15
|
function transformItem(raw) {
|
|
15
16
|
const item = {
|
|
@@ -63,6 +64,25 @@ function transformItem(raw) {
|
|
|
63
64
|
item.folderStatus = raw.folderStatus;
|
|
64
65
|
if (raw.globalPriority)
|
|
65
66
|
item.globalPriority = raw.globalPriority;
|
|
67
|
+
// Extended fields — only include if present
|
|
68
|
+
if (raw.color)
|
|
69
|
+
item.color = raw.color;
|
|
70
|
+
if (raw.workType)
|
|
71
|
+
item.workType = raw.workType;
|
|
72
|
+
if (raw.description)
|
|
73
|
+
item.description = raw.description;
|
|
74
|
+
if (raw.customFieldValues)
|
|
75
|
+
item.customFieldValues = raw.customFieldValues;
|
|
76
|
+
if (raw.createdAt)
|
|
77
|
+
item.createdAt = raw.createdAt;
|
|
78
|
+
if (raw.updatedAt)
|
|
79
|
+
item.updatedAt = raw.updatedAt;
|
|
80
|
+
if (raw.clippedHours !== undefined)
|
|
81
|
+
item.clippedHours = raw.clippedHours;
|
|
82
|
+
if (raw.workLimitHours !== undefined)
|
|
83
|
+
item.workLimitHours = raw.workLimitHours;
|
|
84
|
+
if (raw.workLimitRisk !== undefined)
|
|
85
|
+
item.workLimitRisk = raw.workLimitRisk;
|
|
66
86
|
return item;
|
|
67
87
|
}
|
|
68
88
|
/**
|
|
@@ -103,6 +123,7 @@ export class LPClient {
|
|
|
103
123
|
timesheetTtl: config.cache?.timesheetTtl ?? 60000,
|
|
104
124
|
assignmentsTtl: config.cache?.assignmentsTtl ?? 120000,
|
|
105
125
|
itemsTtl: config.cache?.itemsTtl ?? 300000,
|
|
126
|
+
treeTtl: config.cache?.treeTtl ?? 600000,
|
|
106
127
|
};
|
|
107
128
|
// Initialize retry config with defaults if provided
|
|
108
129
|
if (config.retry) {
|
|
@@ -145,6 +166,13 @@ export class LPClient {
|
|
|
145
166
|
invalidateAssignmentsCache() {
|
|
146
167
|
this.cache?.invalidate('assignments:');
|
|
147
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Invalidate cached workspace tree snapshot only.
|
|
171
|
+
* Use when the workspace structure changes (items created, moved, or deleted).
|
|
172
|
+
*/
|
|
173
|
+
invalidateTreeCache() {
|
|
174
|
+
this.cache?.invalidate('tree');
|
|
175
|
+
}
|
|
148
176
|
/**
|
|
149
177
|
* Make an authenticated request to the LP API
|
|
150
178
|
*
|
|
@@ -189,12 +217,30 @@ export class LPClient {
|
|
|
189
217
|
if (!response.ok) {
|
|
190
218
|
const errorText = await response.text();
|
|
191
219
|
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
192
|
-
return { error:
|
|
220
|
+
return { ok: false, error: message, status: response.status, ...(isDuplicate ? { isDuplicate } : {}) };
|
|
193
221
|
}
|
|
194
|
-
return
|
|
222
|
+
return ok(await parse(response));
|
|
195
223
|
}
|
|
196
224
|
catch (error) {
|
|
197
|
-
return
|
|
225
|
+
return err(getErrorMessage(error), 0);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Fetch and parse with isDuplicate support for mutation methods.
|
|
230
|
+
*/
|
|
231
|
+
async fetchAndParseMutation(url, parse, fetchOptions) {
|
|
232
|
+
try {
|
|
233
|
+
const response = await this.fetch(url, fetchOptions);
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
const errorText = await response.text();
|
|
236
|
+
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
237
|
+
return { ok: false, error: message, status: response.status, isDuplicate };
|
|
238
|
+
}
|
|
239
|
+
const data = await parse(response);
|
|
240
|
+
return { ok: true, data };
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return { ok: false, error: getErrorMessage(error), status: 0 };
|
|
198
244
|
}
|
|
199
245
|
}
|
|
200
246
|
// ============================================================================
|
|
@@ -208,15 +254,15 @@ export class LPClient {
|
|
|
208
254
|
try {
|
|
209
255
|
const response = await this.fetch(url);
|
|
210
256
|
if (response.ok) {
|
|
211
|
-
return {
|
|
257
|
+
return { ok: true };
|
|
212
258
|
}
|
|
213
259
|
if (response.status === 401 || response.status === 403) {
|
|
214
|
-
return
|
|
260
|
+
return err('Invalid or expired API token', response.status);
|
|
215
261
|
}
|
|
216
|
-
return
|
|
262
|
+
return err(`Unexpected response: HTTP ${response.status}`, response.status);
|
|
217
263
|
}
|
|
218
264
|
catch (error) {
|
|
219
|
-
return
|
|
265
|
+
return err(getErrorMessage(error) || 'Connection failed');
|
|
220
266
|
}
|
|
221
267
|
}
|
|
222
268
|
/**
|
|
@@ -224,11 +270,10 @@ export class LPClient {
|
|
|
224
270
|
*/
|
|
225
271
|
async getWorkspaces() {
|
|
226
272
|
const url = `${this.baseUrl}/workspaces/v1`;
|
|
227
|
-
|
|
273
|
+
return this.fetchAndParse(url, async (r) => {
|
|
228
274
|
const result = await r.json();
|
|
229
275
|
return (result.data || []).map(ws => ({ id: ws.id, name: ws.name }));
|
|
230
276
|
});
|
|
231
|
-
return error ? { error } : { workspaces: data };
|
|
232
277
|
}
|
|
233
278
|
// ============================================================================
|
|
234
279
|
// Members
|
|
@@ -239,7 +284,7 @@ export class LPClient {
|
|
|
239
284
|
async getWorkspaceMembers() {
|
|
240
285
|
return this.cached('members', this.cacheTtl.membersTtl, async () => {
|
|
241
286
|
const baseUrl = this.workspaceUrl('users/v1');
|
|
242
|
-
|
|
287
|
+
return paginatedFetch({
|
|
243
288
|
fetchFn: (url) => this.fetch(url),
|
|
244
289
|
baseUrl,
|
|
245
290
|
transform: (data) => data.map(m => ({
|
|
@@ -251,7 +296,6 @@ export class LPClient {
|
|
|
251
296
|
userType: m.userType,
|
|
252
297
|
})),
|
|
253
298
|
});
|
|
254
|
-
return error ? { error } : { members: results };
|
|
255
299
|
});
|
|
256
300
|
}
|
|
257
301
|
// ============================================================================
|
|
@@ -263,17 +307,17 @@ export class LPClient {
|
|
|
263
307
|
async getItem(itemId) {
|
|
264
308
|
return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
265
309
|
const url = this.workspaceUrl(`items/v1?${filterIs('id', itemId)}`);
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
if (!
|
|
310
|
+
const result = await this.fetchAndParse(url, async (r) => {
|
|
311
|
+
const json = await r.json();
|
|
312
|
+
if (!json.data || json.data.length === 0)
|
|
269
313
|
return null;
|
|
270
|
-
return transformItem(
|
|
314
|
+
return transformItem(json.data[0]);
|
|
271
315
|
});
|
|
272
|
-
if (
|
|
273
|
-
return
|
|
274
|
-
if (!data)
|
|
275
|
-
return
|
|
276
|
-
return
|
|
316
|
+
if (!result.ok)
|
|
317
|
+
return result;
|
|
318
|
+
if (!result.data)
|
|
319
|
+
return err(`Item ${itemId} not found`, 404);
|
|
320
|
+
return ok(result.data);
|
|
277
321
|
});
|
|
278
322
|
}
|
|
279
323
|
/**
|
|
@@ -286,14 +330,13 @@ export class LPClient {
|
|
|
286
330
|
*/
|
|
287
331
|
async getItems(itemIds) {
|
|
288
332
|
if (itemIds.length === 0)
|
|
289
|
-
return
|
|
333
|
+
return ok([]);
|
|
290
334
|
const baseUrl = this.workspaceUrl(`items/v1?${filterIn('id', itemIds)}`);
|
|
291
|
-
|
|
335
|
+
return paginatedFetch({
|
|
292
336
|
fetchFn: (url) => this.fetch(url),
|
|
293
337
|
baseUrl,
|
|
294
338
|
transform: (data) => data.map(transformItem),
|
|
295
339
|
});
|
|
296
|
-
return error ? { error } : { items: results };
|
|
297
340
|
}
|
|
298
341
|
/**
|
|
299
342
|
* Get the ancestry chain for an item
|
|
@@ -306,7 +349,7 @@ export class LPClient {
|
|
|
306
349
|
async getItemAncestors(itemId) {
|
|
307
350
|
return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
308
351
|
const url = this.workspaceUrl(`items/v1/${itemId}/ancestors`);
|
|
309
|
-
|
|
352
|
+
return this.fetchAndParse(url, async (r) => {
|
|
310
353
|
const json = (await r.json());
|
|
311
354
|
// Handle both { data: [...] } and direct array responses
|
|
312
355
|
const rawData = Array.isArray(json) ? json : (json.data || []);
|
|
@@ -316,7 +359,6 @@ export class LPClient {
|
|
|
316
359
|
itemType: normalizeItemType(a.itemType),
|
|
317
360
|
})).reverse(); // LP API returns child→root, normalize to root→child
|
|
318
361
|
}, { description: `Get ancestors for item ${itemId}` });
|
|
319
|
-
return error ? { error } : { ancestors: data };
|
|
320
362
|
});
|
|
321
363
|
}
|
|
322
364
|
/**
|
|
@@ -325,12 +367,11 @@ export class LPClient {
|
|
|
325
367
|
async findAssignments(taskId) {
|
|
326
368
|
// parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
|
|
327
369
|
const baseUrl = this.workspaceUrl(`items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`);
|
|
328
|
-
|
|
370
|
+
return paginatedFetch({
|
|
329
371
|
fetchFn: (url) => this.fetch(url),
|
|
330
372
|
baseUrl,
|
|
331
373
|
transform: (data) => data.map(transformItem),
|
|
332
374
|
});
|
|
333
|
-
return error ? { error } : { assignments: results };
|
|
334
375
|
}
|
|
335
376
|
/**
|
|
336
377
|
* Get all assignments for a specific member
|
|
@@ -341,13 +382,12 @@ export class LPClient {
|
|
|
341
382
|
async getMyAssignments(memberId) {
|
|
342
383
|
return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
|
|
343
384
|
const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
|
|
344
|
-
|
|
385
|
+
return paginatedFetch({
|
|
345
386
|
fetchFn: (url) => this.fetch(url),
|
|
346
387
|
baseUrl,
|
|
347
388
|
filter: (data) => data.filter(item => item.userId === memberId),
|
|
348
389
|
transform: (data) => data.map(transformItem),
|
|
349
390
|
});
|
|
350
|
-
return error ? { error } : { assignments: results };
|
|
351
391
|
});
|
|
352
392
|
}
|
|
353
393
|
/**
|
|
@@ -368,11 +408,12 @@ export class LPClient {
|
|
|
368
408
|
*/
|
|
369
409
|
async getMyAssignmentsWithContext(memberId, options) {
|
|
370
410
|
// 1. Get raw assignments
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
return
|
|
411
|
+
const assignResult = await this.getMyAssignments(memberId);
|
|
412
|
+
if (!assignResult.ok)
|
|
413
|
+
return assignResult;
|
|
414
|
+
const assignments = assignResult.data;
|
|
374
415
|
if (assignments.length === 0)
|
|
375
|
-
return
|
|
416
|
+
return ok([]);
|
|
376
417
|
// 2. Handle based on options
|
|
377
418
|
let taskMap = new Map();
|
|
378
419
|
let projectMap = new Map();
|
|
@@ -389,12 +430,12 @@ export class LPClient {
|
|
|
389
430
|
}
|
|
390
431
|
const parentEntries = [...assignmentsByParent.entries()];
|
|
391
432
|
const ancestorResults = await batchMap(parentEntries, 5, async ([parentId, assignment]) => {
|
|
392
|
-
const
|
|
393
|
-
return { parentId, ancestors, error };
|
|
433
|
+
const result = await this.getItemAncestors(assignment.id);
|
|
434
|
+
return { parentId, ancestors: result.data, error: result.ok ? undefined : result };
|
|
394
435
|
});
|
|
395
436
|
const firstError = ancestorResults.find(r => r.error);
|
|
396
437
|
if (firstError) {
|
|
397
|
-
return
|
|
438
|
+
return firstError.error;
|
|
398
439
|
}
|
|
399
440
|
for (const { parentId, ancestors } of ancestorResults) {
|
|
400
441
|
ancestorMap.set(parentId, ancestors);
|
|
@@ -403,72 +444,220 @@ export class LPClient {
|
|
|
403
444
|
else {
|
|
404
445
|
// Original path: batch fetch tasks first
|
|
405
446
|
const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
|
|
406
|
-
const
|
|
407
|
-
if (
|
|
408
|
-
return
|
|
409
|
-
for (const task of
|
|
447
|
+
const taskResult = await this.getItems(taskIds);
|
|
448
|
+
if (!taskResult.ok)
|
|
449
|
+
return taskResult;
|
|
450
|
+
for (const task of taskResult.data || []) {
|
|
410
451
|
taskMap.set(task.id, task);
|
|
411
452
|
}
|
|
412
453
|
if (options?.includeProject) {
|
|
413
454
|
// Also fetch grandparent projects
|
|
414
455
|
const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
|
|
415
|
-
const
|
|
416
|
-
if (
|
|
417
|
-
return
|
|
418
|
-
for (const project of
|
|
456
|
+
const projectResult = await this.getItems(projectIds);
|
|
457
|
+
if (!projectResult.ok)
|
|
458
|
+
return projectResult;
|
|
459
|
+
for (const project of projectResult.data || []) {
|
|
419
460
|
projectMap.set(project.id, project);
|
|
420
461
|
}
|
|
421
462
|
}
|
|
422
463
|
}
|
|
423
464
|
// 3. Merge context into assignments
|
|
424
|
-
return {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
result.projectName = hierarchyAncestors[0].name;
|
|
446
|
-
}
|
|
447
|
-
else {
|
|
448
|
-
result.projectName = null;
|
|
449
|
-
}
|
|
465
|
+
return ok(assignments.map(a => {
|
|
466
|
+
const result = { ...a };
|
|
467
|
+
if (options?.includeHierarchy && a.parentId) {
|
|
468
|
+
// Full hierarchy mode - extract task name from ancestors
|
|
469
|
+
const ancestors = ancestorMap.get(a.parentId);
|
|
470
|
+
result.ancestors = ancestors;
|
|
471
|
+
if (ancestors && ancestors.length > 0) {
|
|
472
|
+
// Extract task name from first Task ancestor
|
|
473
|
+
const taskAncestor = ancestors.find(anc => anc.itemType === 'Task');
|
|
474
|
+
result.taskName = taskAncestor?.name ?? null;
|
|
475
|
+
// Build hierarchyPath from Project and Folder ancestors
|
|
476
|
+
// Exclude system containers (Package, WorkspaceRoot) and Tasks
|
|
477
|
+
const hierarchyAncestors = ancestors
|
|
478
|
+
.filter(anc => anc.itemType === 'Project' || anc.itemType === 'Folder');
|
|
479
|
+
if (hierarchyAncestors.length > 0) {
|
|
480
|
+
result.hierarchyPath = hierarchyAncestors
|
|
481
|
+
.map(anc => anc.name ?? `[${anc.id}]`)
|
|
482
|
+
.join(' › ');
|
|
483
|
+
// Set projectId/projectName from root (first in reversed array)
|
|
484
|
+
result.projectId = hierarchyAncestors[0].id;
|
|
485
|
+
result.projectName = hierarchyAncestors[0].name;
|
|
450
486
|
}
|
|
451
487
|
else {
|
|
452
|
-
result.taskName = null;
|
|
453
488
|
result.projectName = null;
|
|
454
489
|
}
|
|
455
490
|
}
|
|
456
491
|
else {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
result.taskName = task?.name ?? null;
|
|
460
|
-
if (options?.includeProject) {
|
|
461
|
-
const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
|
|
462
|
-
result.projectId = project?.id;
|
|
463
|
-
result.projectName = project?.name ?? null;
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
result.projectName = null;
|
|
467
|
-
}
|
|
492
|
+
result.taskName = null;
|
|
493
|
+
result.projectName = null;
|
|
468
494
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
// Original path - use taskMap
|
|
498
|
+
const task = a.parentId ? taskMap.get(a.parentId) : undefined;
|
|
499
|
+
result.taskName = task?.name ?? null;
|
|
500
|
+
if (options?.includeProject) {
|
|
501
|
+
const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
|
|
502
|
+
result.projectId = project?.id;
|
|
503
|
+
result.projectName = project?.name ?? null;
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
result.projectName = null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return result;
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
// ============================================================================
|
|
513
|
+
// Item Queries (Rich Filtering)
|
|
514
|
+
// ============================================================================
|
|
515
|
+
/**
|
|
516
|
+
* Query items with LP API filters
|
|
517
|
+
*
|
|
518
|
+
* General-purpose method that exposes the full filtering capabilities of
|
|
519
|
+
* the LP items endpoint. Not cached — use higher-level methods like
|
|
520
|
+
* getWorkspaceTree() for cached access.
|
|
521
|
+
*
|
|
522
|
+
* @see https://api-docs.liquidplanner.com/docs/plan-items
|
|
523
|
+
*
|
|
524
|
+
* @param options - Filter options (all optional, combined with AND)
|
|
525
|
+
*/
|
|
526
|
+
async findItems(options) {
|
|
527
|
+
const filters = [];
|
|
528
|
+
// Identity
|
|
529
|
+
if (options.parentId !== undefined)
|
|
530
|
+
filters.push(filterIs('parentId', options.parentId));
|
|
531
|
+
if (options.itemType)
|
|
532
|
+
filters.push(filterIs('itemType', options.itemType));
|
|
533
|
+
// Status
|
|
534
|
+
if (options.taskStatusGroup)
|
|
535
|
+
filters.push(filterIs('taskStatusGroup', options.taskStatusGroup));
|
|
536
|
+
if (options.taskStatusGroupNot)
|
|
537
|
+
filters.push(filterIsNot('taskStatusGroup', options.taskStatusGroupNot));
|
|
538
|
+
if (options.taskStatusId !== undefined)
|
|
539
|
+
filters.push(filterIs('taskStatusId', options.taskStatusId));
|
|
540
|
+
if (options.packageStatus)
|
|
541
|
+
filters.push(filterIs('packageStatus', options.packageStatus));
|
|
542
|
+
if (options.folderStatus)
|
|
543
|
+
filters.push(filterIs('folderStatus', options.folderStatus));
|
|
544
|
+
// Scheduling
|
|
545
|
+
if (options.scheduleDirective)
|
|
546
|
+
filters.push(filterIs('scheduleDirective', options.scheduleDirective));
|
|
547
|
+
if (options.expectedStartAfter)
|
|
548
|
+
filters.push(filterAfter('expectedStart', options.expectedStartAfter));
|
|
549
|
+
if (options.expectedFinishBefore)
|
|
550
|
+
filters.push(filterBefore('expectedFinish', options.expectedFinishBefore));
|
|
551
|
+
if (options.targetStartAfter)
|
|
552
|
+
filters.push(filterAfter('targetStart', options.targetStartAfter));
|
|
553
|
+
if (options.targetFinishBefore)
|
|
554
|
+
filters.push(filterBefore('targetFinish', options.targetFinishBefore));
|
|
555
|
+
if (options.doneDateAfter)
|
|
556
|
+
filters.push(filterAfter('doneDate', options.doneDateAfter));
|
|
557
|
+
// Flags
|
|
558
|
+
if (options.late !== undefined)
|
|
559
|
+
filters.push(filterIs('late', String(options.late)));
|
|
560
|
+
if (options.workLimitRisk !== undefined)
|
|
561
|
+
filters.push(filterIs('workLimitRisk', String(options.workLimitRisk)));
|
|
562
|
+
if (options.hasFiles !== undefined)
|
|
563
|
+
filters.push(filterIs('hasFiles', String(options.hasFiles)));
|
|
564
|
+
// Search
|
|
565
|
+
if (options.name)
|
|
566
|
+
filters.push(filterIs('name', options.name));
|
|
567
|
+
// Custom fields
|
|
568
|
+
if (options.customFieldValues) {
|
|
569
|
+
for (const [key, value] of Object.entries(options.customFieldValues)) {
|
|
570
|
+
filters.push(filterIs(`customFieldValues.${key}`, value));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const query = filters.length > 0 ? `?${joinFilters(...filters)}` : '';
|
|
574
|
+
const baseUrl = this.workspaceUrl(`items/v1${query}`);
|
|
575
|
+
return paginatedFetch({
|
|
576
|
+
fetchFn: (url) => this.fetch(url),
|
|
577
|
+
baseUrl,
|
|
578
|
+
transform: (data) => data.map(transformItem),
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Get direct children of an item
|
|
583
|
+
*
|
|
584
|
+
* Uses parentId[is] filter to fetch immediate descendants.
|
|
585
|
+
* Optionally filter by item type.
|
|
586
|
+
*
|
|
587
|
+
* @param parentId - The parent item ID
|
|
588
|
+
* @param options - Optional item type filter
|
|
589
|
+
*/
|
|
590
|
+
async getChildren(parentId, options) {
|
|
591
|
+
return this.findItems({
|
|
592
|
+
parentId,
|
|
593
|
+
itemType: options?.itemType,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
// ============================================================================
|
|
597
|
+
// Workspace Tree
|
|
598
|
+
// ============================================================================
|
|
599
|
+
/**
|
|
600
|
+
* Fetch a snapshot of the active workspace tree
|
|
601
|
+
*
|
|
602
|
+
* Fetches all workspace items in paginated API calls, then builds a
|
|
603
|
+
* navigable tree in memory from parentId relationships.
|
|
604
|
+
*
|
|
605
|
+
* The result is cached with treeTtl (default: 10 minutes).
|
|
606
|
+
* Use invalidateTreeCache() to force a refresh.
|
|
607
|
+
*
|
|
608
|
+
* After the initial fetch, all hierarchy queries (ancestors, paths, assignments
|
|
609
|
+
* with context) can be answered from the cached tree with zero API calls.
|
|
610
|
+
*/
|
|
611
|
+
async getWorkspaceTree() {
|
|
612
|
+
return this.cached('tree', this.cacheTtl.treeTtl, async () => {
|
|
613
|
+
// Fetch all workspace items in paginated calls
|
|
614
|
+
const result = await this.findItems({});
|
|
615
|
+
if (!result.ok)
|
|
616
|
+
return err(result.error, result.status);
|
|
617
|
+
const tree = buildTree(result.data);
|
|
618
|
+
return ok(tree);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Get a member's active work with full context from the workspace tree
|
|
623
|
+
*
|
|
624
|
+
* This is the optimized alternative to getMyAssignmentsWithContext().
|
|
625
|
+
* Uses the workspace tree snapshot (cached) to resolve hierarchy locally,
|
|
626
|
+
* eliminating the N+1 ancestor request pattern.
|
|
627
|
+
*
|
|
628
|
+
* API calls: 0 (if tree cached) or 1-3 (cold load)
|
|
629
|
+
*
|
|
630
|
+
* @param memberId - The member ID to get work for
|
|
631
|
+
*/
|
|
632
|
+
async getMyWork(memberId) {
|
|
633
|
+
const treeResult = await this.getWorkspaceTree();
|
|
634
|
+
if (!treeResult.ok)
|
|
635
|
+
return err(treeResult.error, treeResult.status);
|
|
636
|
+
const tree = treeResult.data;
|
|
637
|
+
const assignments = [];
|
|
638
|
+
for (const node of tree.byId.values()) {
|
|
639
|
+
if (node.itemType !== 'Assignment' || node.userId !== memberId)
|
|
640
|
+
continue;
|
|
641
|
+
const ancestors = getTreeAncestors(tree, node.id);
|
|
642
|
+
const taskAncestor = ancestors.find(a => a.itemType === 'Task');
|
|
643
|
+
const hierarchyAncestors = ancestors.filter(a => a.itemType === 'Project' || a.itemType === 'Folder');
|
|
644
|
+
const { children: _, ...itemFields } = node;
|
|
645
|
+
const result = { ...itemFields };
|
|
646
|
+
result.taskName = taskAncestor?.name ?? null;
|
|
647
|
+
result.ancestors = ancestors;
|
|
648
|
+
if (hierarchyAncestors.length > 0) {
|
|
649
|
+
result.projectId = hierarchyAncestors[0].id;
|
|
650
|
+
result.projectName = hierarchyAncestors[0].name;
|
|
651
|
+
result.hierarchyPath = hierarchyAncestors
|
|
652
|
+
.map(a => a.name ?? `[${a.id}]`)
|
|
653
|
+
.join(' › ');
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
result.projectName = null;
|
|
657
|
+
}
|
|
658
|
+
assignments.push(result);
|
|
659
|
+
}
|
|
660
|
+
return ok({ assignments, treeItemCount: tree.itemCount });
|
|
472
661
|
}
|
|
473
662
|
// ============================================================================
|
|
474
663
|
// Cost Codes
|
|
@@ -479,7 +668,7 @@ export class LPClient {
|
|
|
479
668
|
async getCostCodes() {
|
|
480
669
|
return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
|
|
481
670
|
const baseUrl = this.workspaceUrl('cost-codes/v1');
|
|
482
|
-
|
|
671
|
+
return paginatedFetch({
|
|
483
672
|
fetchFn: (url) => this.fetch(url),
|
|
484
673
|
baseUrl,
|
|
485
674
|
transform: (data) => data.map(cc => ({
|
|
@@ -488,7 +677,6 @@ export class LPClient {
|
|
|
488
677
|
billable: cc.billable,
|
|
489
678
|
})),
|
|
490
679
|
});
|
|
491
|
-
return error ? { error } : { costCodes: results };
|
|
492
680
|
});
|
|
493
681
|
}
|
|
494
682
|
// ============================================================================
|
|
@@ -517,15 +705,11 @@ export class LPClient {
|
|
|
517
705
|
if (note) {
|
|
518
706
|
body.note = note;
|
|
519
707
|
}
|
|
520
|
-
|
|
708
|
+
return this.fetchAndParseMutation(url, async (r) => {
|
|
521
709
|
this.cache?.invalidate('timesheet:');
|
|
522
710
|
const result = await r.json();
|
|
523
711
|
return result.id;
|
|
524
712
|
}, { method: 'POST', body });
|
|
525
|
-
if (error) {
|
|
526
|
-
return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
|
|
527
|
-
}
|
|
528
|
-
return { success: true, entryId: data };
|
|
529
713
|
}
|
|
530
714
|
/**
|
|
531
715
|
* Get timesheet entries for one or more dates
|
|
@@ -552,7 +736,7 @@ export class LPClient {
|
|
|
552
736
|
if (itemId) {
|
|
553
737
|
baseUrl += `&${filterIs('itemId', itemId)}`;
|
|
554
738
|
}
|
|
555
|
-
|
|
739
|
+
return paginatedFetch({
|
|
556
740
|
fetchFn: (url) => this.fetch(url),
|
|
557
741
|
baseUrl,
|
|
558
742
|
transform: (data) => data.map(entry => ({
|
|
@@ -565,7 +749,6 @@ export class LPClient {
|
|
|
565
749
|
userId: entry.userId,
|
|
566
750
|
})),
|
|
567
751
|
});
|
|
568
|
-
return error ? { error } : { entries: results };
|
|
569
752
|
});
|
|
570
753
|
}
|
|
571
754
|
/**
|
|
@@ -601,13 +784,10 @@ export class LPClient {
|
|
|
601
784
|
note: updates.note ?? '',
|
|
602
785
|
userId: existingEntry.userId,
|
|
603
786
|
};
|
|
604
|
-
|
|
787
|
+
return this.fetchAndParseMutation(url, async () => {
|
|
605
788
|
this.cache?.invalidate('timesheet:');
|
|
789
|
+
return entryId;
|
|
606
790
|
}, { method: 'PUT', body });
|
|
607
|
-
if (error) {
|
|
608
|
-
return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
|
|
609
|
-
}
|
|
610
|
-
return { success: true, entryId };
|
|
611
791
|
}
|
|
612
792
|
/**
|
|
613
793
|
* Create or update a timesheet entry (upsert)
|
|
@@ -646,14 +826,14 @@ export class LPClient {
|
|
|
646
826
|
async upsertTimesheetEntry(entry, options = {}) {
|
|
647
827
|
const { accumulate = true } = options;
|
|
648
828
|
// Fetch existing entries for this date/item first
|
|
649
|
-
const
|
|
650
|
-
if (
|
|
651
|
-
return {
|
|
829
|
+
const fetchResult = await this.getTimesheetEntries(entry.date, entry.itemId);
|
|
830
|
+
if (!fetchResult.ok) {
|
|
831
|
+
return { ok: false, error: fetchResult.error, status: fetchResult.status };
|
|
652
832
|
}
|
|
653
833
|
// Find matching entry
|
|
654
834
|
// If no costCodeId specified, match any entry (LP uses assignment's default)
|
|
655
835
|
// If costCodeId specified, match exactly
|
|
656
|
-
const existingEntry =
|
|
836
|
+
const existingEntry = fetchResult.data?.find((e) => {
|
|
657
837
|
if (entry.costCodeId === undefined || entry.costCodeId === null) {
|
|
658
838
|
return true;
|
|
659
839
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -28,12 +28,12 @@ export declare function parseLPErrorResponse(errorText: string, statusCode: numb
|
|
|
28
28
|
*/
|
|
29
29
|
export declare class LPError extends Error {
|
|
30
30
|
/** HTTP status code */
|
|
31
|
-
|
|
31
|
+
status: number;
|
|
32
32
|
/** Whether this is a duplicate entry error */
|
|
33
33
|
isDuplicate: boolean;
|
|
34
34
|
/** Raw error response */
|
|
35
35
|
rawResponse?: string;
|
|
36
|
-
constructor(message: string,
|
|
36
|
+
constructor(message: string, status: number, options?: {
|
|
37
37
|
isDuplicate?: boolean;
|
|
38
38
|
rawResponse?: string;
|
|
39
39
|
});
|
package/dist/errors.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - {"message":"..."} or {"error":"..."}
|
|
7
7
|
* - Plain text
|
|
8
8
|
*/
|
|
9
|
+
import { parseJsonErrorResponse } from '@markwharton/api-core';
|
|
9
10
|
/** Error code for duplicate entry errors */
|
|
10
11
|
const LP_ERROR_CODE_DUPLICATE = 'duplicate_value';
|
|
11
12
|
/**
|
|
@@ -25,10 +26,8 @@ export function parseLPErrorResponse(errorText, statusCode) {
|
|
|
25
26
|
const message = firstError?.detail || firstError?.title || `HTTP ${statusCode}`;
|
|
26
27
|
return { message, isDuplicate };
|
|
27
28
|
}
|
|
28
|
-
// Fallback to
|
|
29
|
-
return
|
|
30
|
-
message: errorJson.message || errorJson.error || `HTTP ${statusCode}`,
|
|
31
|
-
};
|
|
29
|
+
// Fallback to common JSON error formats via api-core
|
|
30
|
+
return parseJsonErrorResponse(errorText, statusCode);
|
|
32
31
|
}
|
|
33
32
|
catch {
|
|
34
33
|
// Not JSON, return as-is or fallback
|
|
@@ -39,10 +38,10 @@ export function parseLPErrorResponse(errorText, statusCode) {
|
|
|
39
38
|
* Custom error class for LP API errors
|
|
40
39
|
*/
|
|
41
40
|
export class LPError extends Error {
|
|
42
|
-
constructor(message,
|
|
41
|
+
constructor(message, status, options) {
|
|
43
42
|
super(message);
|
|
44
43
|
this.name = 'LPError';
|
|
45
|
-
this.
|
|
44
|
+
this.status = status;
|
|
46
45
|
this.isDuplicate = options?.isDuplicate ?? false;
|
|
47
46
|
this.rawResponse = options?.rawResponse;
|
|
48
47
|
}
|