@markwharton/liquidplanner 1.12.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/dist/client.d.ts +24 -62
- package/dist/client.js +117 -115
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -6
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/types.d.ts +6 -42
- package/dist/utils.d.ts +3 -5
- package/dist/utils.js +5 -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.d.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @see https://api-docs.liquidplanner.com/
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type { Result } from '@markwharton/api-core';
|
|
10
|
+
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor, LPFindItemsOptions, LPWorkspaceTree } from './types.js';
|
|
10
11
|
/**
|
|
11
12
|
* LiquidPlanner API Client
|
|
12
13
|
*
|
|
@@ -80,34 +81,26 @@ export declare class LPClient {
|
|
|
80
81
|
* Fetch a URL and parse the response, with standardized error handling.
|
|
81
82
|
*/
|
|
82
83
|
private fetchAndParse;
|
|
84
|
+
/**
|
|
85
|
+
* Fetch and parse with isDuplicate support for mutation methods.
|
|
86
|
+
*/
|
|
87
|
+
private fetchAndParseMutation;
|
|
83
88
|
/**
|
|
84
89
|
* Validate the API token by listing workspaces
|
|
85
90
|
*/
|
|
86
|
-
validateToken(): Promise<
|
|
87
|
-
valid: boolean;
|
|
88
|
-
error?: string;
|
|
89
|
-
}>;
|
|
91
|
+
validateToken(): Promise<Result<void>>;
|
|
90
92
|
/**
|
|
91
93
|
* Get all workspaces accessible to the API token
|
|
92
94
|
*/
|
|
93
|
-
getWorkspaces(): Promise<
|
|
94
|
-
workspaces?: LPWorkspace[];
|
|
95
|
-
error?: LPErrorInfo;
|
|
96
|
-
}>;
|
|
95
|
+
getWorkspaces(): Promise<Result<LPWorkspace[]>>;
|
|
97
96
|
/**
|
|
98
97
|
* Get all members in the workspace (with pagination)
|
|
99
98
|
*/
|
|
100
|
-
getWorkspaceMembers(): Promise<
|
|
101
|
-
members?: LPMember[];
|
|
102
|
-
error?: LPErrorInfo;
|
|
103
|
-
}>;
|
|
99
|
+
getWorkspaceMembers(): Promise<Result<LPMember[]>>;
|
|
104
100
|
/**
|
|
105
101
|
* Get a single item by ID
|
|
106
102
|
*/
|
|
107
|
-
getItem(itemId: number): Promise<
|
|
108
|
-
item?: LPItem;
|
|
109
|
-
error?: LPErrorInfo;
|
|
110
|
-
}>;
|
|
103
|
+
getItem(itemId: number): Promise<Result<LPItem>>;
|
|
111
104
|
/**
|
|
112
105
|
* Get multiple items by ID in a single request (batch fetch)
|
|
113
106
|
*
|
|
@@ -116,10 +109,7 @@ export declare class LPClient {
|
|
|
116
109
|
*
|
|
117
110
|
* @param itemIds - Array of item IDs to fetch
|
|
118
111
|
*/
|
|
119
|
-
getItems(itemIds: number[]): Promise<
|
|
120
|
-
items?: LPItem[];
|
|
121
|
-
error?: LPErrorInfo;
|
|
122
|
-
}>;
|
|
112
|
+
getItems(itemIds: number[]): Promise<Result<LPItem[]>>;
|
|
123
113
|
/**
|
|
124
114
|
* Get the ancestry chain for an item
|
|
125
115
|
*
|
|
@@ -128,27 +118,18 @@ export declare class LPClient {
|
|
|
128
118
|
*
|
|
129
119
|
* @param itemId - The item ID to get ancestors for
|
|
130
120
|
*/
|
|
131
|
-
getItemAncestors(itemId: number): Promise<
|
|
132
|
-
ancestors?: LPAncestor[];
|
|
133
|
-
error?: LPErrorInfo;
|
|
134
|
-
}>;
|
|
121
|
+
getItemAncestors(itemId: number): Promise<Result<LPAncestor[]>>;
|
|
135
122
|
/**
|
|
136
123
|
* Find all assignments under a task (with pagination)
|
|
137
124
|
*/
|
|
138
|
-
findAssignments(taskId: number): Promise<
|
|
139
|
-
assignments?: LPItem[];
|
|
140
|
-
error?: LPErrorInfo;
|
|
141
|
-
}>;
|
|
125
|
+
findAssignments(taskId: number): Promise<Result<LPItem[]>>;
|
|
142
126
|
/**
|
|
143
127
|
* Get all assignments for a specific member
|
|
144
128
|
*
|
|
145
129
|
* This enables PWA apps to show a task picker populated from LP directly.
|
|
146
130
|
* Note: userId is not a supported filter field in the LP API, so we filter client-side.
|
|
147
131
|
*/
|
|
148
|
-
getMyAssignments(memberId: number): Promise<
|
|
149
|
-
assignments?: LPItem[];
|
|
150
|
-
error?: LPErrorInfo;
|
|
151
|
-
}>;
|
|
132
|
+
getMyAssignments(memberId: number): Promise<Result<LPItem[]>>;
|
|
152
133
|
/**
|
|
153
134
|
* Get assignments for a member with parent task names resolved
|
|
154
135
|
*
|
|
@@ -168,10 +149,7 @@ export declare class LPClient {
|
|
|
168
149
|
getMyAssignmentsWithContext(memberId: number, options?: {
|
|
169
150
|
includeProject?: boolean;
|
|
170
151
|
includeHierarchy?: boolean;
|
|
171
|
-
}): Promise<
|
|
172
|
-
assignments?: LPAssignmentWithContext[];
|
|
173
|
-
error?: LPErrorInfo;
|
|
174
|
-
}>;
|
|
152
|
+
}): Promise<Result<LPAssignmentWithContext[]>>;
|
|
175
153
|
/**
|
|
176
154
|
* Query items with LP API filters
|
|
177
155
|
*
|
|
@@ -183,10 +161,7 @@ export declare class LPClient {
|
|
|
183
161
|
*
|
|
184
162
|
* @param options - Filter options (all optional, combined with AND)
|
|
185
163
|
*/
|
|
186
|
-
findItems(options: LPFindItemsOptions): Promise<
|
|
187
|
-
items?: LPItem[];
|
|
188
|
-
error?: LPErrorInfo;
|
|
189
|
-
}>;
|
|
164
|
+
findItems(options: LPFindItemsOptions): Promise<Result<LPItem[]>>;
|
|
190
165
|
/**
|
|
191
166
|
* Get direct children of an item
|
|
192
167
|
*
|
|
@@ -198,10 +173,7 @@ export declare class LPClient {
|
|
|
198
173
|
*/
|
|
199
174
|
getChildren(parentId: number, options?: {
|
|
200
175
|
itemType?: string;
|
|
201
|
-
}): Promise<
|
|
202
|
-
items?: LPItem[];
|
|
203
|
-
error?: LPErrorInfo;
|
|
204
|
-
}>;
|
|
176
|
+
}): Promise<Result<LPItem[]>>;
|
|
205
177
|
/**
|
|
206
178
|
* Fetch a snapshot of the active workspace tree
|
|
207
179
|
*
|
|
@@ -214,10 +186,7 @@ export declare class LPClient {
|
|
|
214
186
|
* After the initial fetch, all hierarchy queries (ancestors, paths, assignments
|
|
215
187
|
* with context) can be answered from the cached tree with zero API calls.
|
|
216
188
|
*/
|
|
217
|
-
getWorkspaceTree(): Promise<
|
|
218
|
-
tree?: LPWorkspaceTree;
|
|
219
|
-
error?: LPErrorInfo;
|
|
220
|
-
}>;
|
|
189
|
+
getWorkspaceTree(): Promise<Result<LPWorkspaceTree>>;
|
|
221
190
|
/**
|
|
222
191
|
* Get a member's active work with full context from the workspace tree
|
|
223
192
|
*
|
|
@@ -229,18 +198,14 @@ export declare class LPClient {
|
|
|
229
198
|
*
|
|
230
199
|
* @param memberId - The member ID to get work for
|
|
231
200
|
*/
|
|
232
|
-
getMyWork(memberId: number): Promise<{
|
|
233
|
-
assignments
|
|
234
|
-
treeItemCount
|
|
235
|
-
|
|
236
|
-
}>;
|
|
201
|
+
getMyWork(memberId: number): Promise<Result<{
|
|
202
|
+
assignments: LPAssignmentWithContext[];
|
|
203
|
+
treeItemCount: number;
|
|
204
|
+
}>>;
|
|
237
205
|
/**
|
|
238
206
|
* Get all cost codes in the workspace (with pagination)
|
|
239
207
|
*/
|
|
240
|
-
getCostCodes(): Promise<
|
|
241
|
-
costCodes?: LPCostCode[];
|
|
242
|
-
error?: LPErrorInfo;
|
|
243
|
-
}>;
|
|
208
|
+
getCostCodes(): Promise<Result<LPCostCode[]>>;
|
|
244
209
|
/**
|
|
245
210
|
* Create a timesheet entry (log time)
|
|
246
211
|
*
|
|
@@ -262,10 +227,7 @@ export declare class LPClient {
|
|
|
262
227
|
* @param date - Date(s) in YYYY-MM-DD format (string or array)
|
|
263
228
|
* @param itemId - Optional item ID to filter by
|
|
264
229
|
*/
|
|
265
|
-
getTimesheetEntries(date: string | string[], itemId?: number): Promise<
|
|
266
|
-
entries?: LPTimesheetEntryWithId[];
|
|
267
|
-
error?: LPErrorInfo;
|
|
268
|
-
}>;
|
|
230
|
+
getTimesheetEntries(date: string | string[], itemId?: number): Promise<Result<LPTimesheetEntryWithId[]>>;
|
|
269
231
|
/**
|
|
270
232
|
* Update an existing timesheet entry
|
|
271
233
|
*
|
package/dist/client.js
CHANGED
|
@@ -10,7 +10,7 @@ import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIsN
|
|
|
10
10
|
import { buildTree, getTreeAncestors } from './tree.js';
|
|
11
11
|
import { parseLPErrorResponse } from './errors.js';
|
|
12
12
|
import { LP_API_BASE } from './constants.js';
|
|
13
|
-
import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
|
|
13
|
+
import { TTLCache, batchMap, getErrorMessage, fetchWithRetry, ok, err } from '@markwharton/api-core';
|
|
14
14
|
/** Transform raw API item to LPItem, preserving scheduling and effort fields */
|
|
15
15
|
function transformItem(raw) {
|
|
16
16
|
const item = {
|
|
@@ -217,12 +217,30 @@ export class LPClient {
|
|
|
217
217
|
if (!response.ok) {
|
|
218
218
|
const errorText = await response.text();
|
|
219
219
|
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
220
|
-
return { error:
|
|
220
|
+
return { ok: false, error: message, status: response.status, ...(isDuplicate ? { isDuplicate } : {}) };
|
|
221
221
|
}
|
|
222
|
-
return
|
|
222
|
+
return ok(await parse(response));
|
|
223
223
|
}
|
|
224
224
|
catch (error) {
|
|
225
|
-
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 };
|
|
226
244
|
}
|
|
227
245
|
}
|
|
228
246
|
// ============================================================================
|
|
@@ -236,15 +254,15 @@ export class LPClient {
|
|
|
236
254
|
try {
|
|
237
255
|
const response = await this.fetch(url);
|
|
238
256
|
if (response.ok) {
|
|
239
|
-
return {
|
|
257
|
+
return { ok: true };
|
|
240
258
|
}
|
|
241
259
|
if (response.status === 401 || response.status === 403) {
|
|
242
|
-
return
|
|
260
|
+
return err('Invalid or expired API token', response.status);
|
|
243
261
|
}
|
|
244
|
-
return
|
|
262
|
+
return err(`Unexpected response: HTTP ${response.status}`, response.status);
|
|
245
263
|
}
|
|
246
264
|
catch (error) {
|
|
247
|
-
return
|
|
265
|
+
return err(getErrorMessage(error) || 'Connection failed');
|
|
248
266
|
}
|
|
249
267
|
}
|
|
250
268
|
/**
|
|
@@ -252,11 +270,10 @@ export class LPClient {
|
|
|
252
270
|
*/
|
|
253
271
|
async getWorkspaces() {
|
|
254
272
|
const url = `${this.baseUrl}/workspaces/v1`;
|
|
255
|
-
|
|
273
|
+
return this.fetchAndParse(url, async (r) => {
|
|
256
274
|
const result = await r.json();
|
|
257
275
|
return (result.data || []).map(ws => ({ id: ws.id, name: ws.name }));
|
|
258
276
|
});
|
|
259
|
-
return error ? { error } : { workspaces: data };
|
|
260
277
|
}
|
|
261
278
|
// ============================================================================
|
|
262
279
|
// Members
|
|
@@ -267,7 +284,7 @@ export class LPClient {
|
|
|
267
284
|
async getWorkspaceMembers() {
|
|
268
285
|
return this.cached('members', this.cacheTtl.membersTtl, async () => {
|
|
269
286
|
const baseUrl = this.workspaceUrl('users/v1');
|
|
270
|
-
|
|
287
|
+
return paginatedFetch({
|
|
271
288
|
fetchFn: (url) => this.fetch(url),
|
|
272
289
|
baseUrl,
|
|
273
290
|
transform: (data) => data.map(m => ({
|
|
@@ -279,7 +296,6 @@ export class LPClient {
|
|
|
279
296
|
userType: m.userType,
|
|
280
297
|
})),
|
|
281
298
|
});
|
|
282
|
-
return error ? { error } : { members: results };
|
|
283
299
|
});
|
|
284
300
|
}
|
|
285
301
|
// ============================================================================
|
|
@@ -291,17 +307,17 @@ export class LPClient {
|
|
|
291
307
|
async getItem(itemId) {
|
|
292
308
|
return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
293
309
|
const url = this.workspaceUrl(`items/v1?${filterIs('id', itemId)}`);
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
if (!
|
|
310
|
+
const result = await this.fetchAndParse(url, async (r) => {
|
|
311
|
+
const json = await r.json();
|
|
312
|
+
if (!json.data || json.data.length === 0)
|
|
297
313
|
return null;
|
|
298
|
-
return transformItem(
|
|
314
|
+
return transformItem(json.data[0]);
|
|
299
315
|
});
|
|
300
|
-
if (
|
|
301
|
-
return
|
|
302
|
-
if (!data)
|
|
303
|
-
return
|
|
304
|
-
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);
|
|
305
321
|
});
|
|
306
322
|
}
|
|
307
323
|
/**
|
|
@@ -314,14 +330,13 @@ export class LPClient {
|
|
|
314
330
|
*/
|
|
315
331
|
async getItems(itemIds) {
|
|
316
332
|
if (itemIds.length === 0)
|
|
317
|
-
return
|
|
333
|
+
return ok([]);
|
|
318
334
|
const baseUrl = this.workspaceUrl(`items/v1?${filterIn('id', itemIds)}`);
|
|
319
|
-
|
|
335
|
+
return paginatedFetch({
|
|
320
336
|
fetchFn: (url) => this.fetch(url),
|
|
321
337
|
baseUrl,
|
|
322
338
|
transform: (data) => data.map(transformItem),
|
|
323
339
|
});
|
|
324
|
-
return error ? { error } : { items: results };
|
|
325
340
|
}
|
|
326
341
|
/**
|
|
327
342
|
* Get the ancestry chain for an item
|
|
@@ -334,7 +349,7 @@ export class LPClient {
|
|
|
334
349
|
async getItemAncestors(itemId) {
|
|
335
350
|
return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
336
351
|
const url = this.workspaceUrl(`items/v1/${itemId}/ancestors`);
|
|
337
|
-
|
|
352
|
+
return this.fetchAndParse(url, async (r) => {
|
|
338
353
|
const json = (await r.json());
|
|
339
354
|
// Handle both { data: [...] } and direct array responses
|
|
340
355
|
const rawData = Array.isArray(json) ? json : (json.data || []);
|
|
@@ -344,7 +359,6 @@ export class LPClient {
|
|
|
344
359
|
itemType: normalizeItemType(a.itemType),
|
|
345
360
|
})).reverse(); // LP API returns child→root, normalize to root→child
|
|
346
361
|
}, { description: `Get ancestors for item ${itemId}` });
|
|
347
|
-
return error ? { error } : { ancestors: data };
|
|
348
362
|
});
|
|
349
363
|
}
|
|
350
364
|
/**
|
|
@@ -353,12 +367,11 @@ export class LPClient {
|
|
|
353
367
|
async findAssignments(taskId) {
|
|
354
368
|
// parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
|
|
355
369
|
const baseUrl = this.workspaceUrl(`items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`);
|
|
356
|
-
|
|
370
|
+
return paginatedFetch({
|
|
357
371
|
fetchFn: (url) => this.fetch(url),
|
|
358
372
|
baseUrl,
|
|
359
373
|
transform: (data) => data.map(transformItem),
|
|
360
374
|
});
|
|
361
|
-
return error ? { error } : { assignments: results };
|
|
362
375
|
}
|
|
363
376
|
/**
|
|
364
377
|
* Get all assignments for a specific member
|
|
@@ -369,13 +382,12 @@ export class LPClient {
|
|
|
369
382
|
async getMyAssignments(memberId) {
|
|
370
383
|
return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
|
|
371
384
|
const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
|
|
372
|
-
|
|
385
|
+
return paginatedFetch({
|
|
373
386
|
fetchFn: (url) => this.fetch(url),
|
|
374
387
|
baseUrl,
|
|
375
388
|
filter: (data) => data.filter(item => item.userId === memberId),
|
|
376
389
|
transform: (data) => data.map(transformItem),
|
|
377
390
|
});
|
|
378
|
-
return error ? { error } : { assignments: results };
|
|
379
391
|
});
|
|
380
392
|
}
|
|
381
393
|
/**
|
|
@@ -396,11 +408,12 @@ export class LPClient {
|
|
|
396
408
|
*/
|
|
397
409
|
async getMyAssignmentsWithContext(memberId, options) {
|
|
398
410
|
// 1. Get raw assignments
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
401
|
-
return
|
|
411
|
+
const assignResult = await this.getMyAssignments(memberId);
|
|
412
|
+
if (!assignResult.ok)
|
|
413
|
+
return assignResult;
|
|
414
|
+
const assignments = assignResult.data;
|
|
402
415
|
if (assignments.length === 0)
|
|
403
|
-
return
|
|
416
|
+
return ok([]);
|
|
404
417
|
// 2. Handle based on options
|
|
405
418
|
let taskMap = new Map();
|
|
406
419
|
let projectMap = new Map();
|
|
@@ -417,12 +430,12 @@ export class LPClient {
|
|
|
417
430
|
}
|
|
418
431
|
const parentEntries = [...assignmentsByParent.entries()];
|
|
419
432
|
const ancestorResults = await batchMap(parentEntries, 5, async ([parentId, assignment]) => {
|
|
420
|
-
const
|
|
421
|
-
return { parentId, ancestors, error };
|
|
433
|
+
const result = await this.getItemAncestors(assignment.id);
|
|
434
|
+
return { parentId, ancestors: result.data, error: result.ok ? undefined : result };
|
|
422
435
|
});
|
|
423
436
|
const firstError = ancestorResults.find(r => r.error);
|
|
424
437
|
if (firstError) {
|
|
425
|
-
return
|
|
438
|
+
return firstError.error;
|
|
426
439
|
}
|
|
427
440
|
for (const { parentId, ancestors } of ancestorResults) {
|
|
428
441
|
ancestorMap.set(parentId, ancestors);
|
|
@@ -431,72 +444,70 @@ export class LPClient {
|
|
|
431
444
|
else {
|
|
432
445
|
// Original path: batch fetch tasks first
|
|
433
446
|
const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
|
|
434
|
-
const
|
|
435
|
-
if (
|
|
436
|
-
return
|
|
437
|
-
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 || []) {
|
|
438
451
|
taskMap.set(task.id, task);
|
|
439
452
|
}
|
|
440
453
|
if (options?.includeProject) {
|
|
441
454
|
// Also fetch grandparent projects
|
|
442
455
|
const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
|
|
443
|
-
const
|
|
444
|
-
if (
|
|
445
|
-
return
|
|
446
|
-
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 || []) {
|
|
447
460
|
projectMap.set(project.id, project);
|
|
448
461
|
}
|
|
449
462
|
}
|
|
450
463
|
}
|
|
451
464
|
// 3. Merge context into assignments
|
|
452
|
-
return {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
result.projectName = hierarchyAncestors[0].name;
|
|
474
|
-
}
|
|
475
|
-
else {
|
|
476
|
-
result.projectName = null;
|
|
477
|
-
}
|
|
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;
|
|
478
486
|
}
|
|
479
487
|
else {
|
|
480
|
-
result.taskName = null;
|
|
481
488
|
result.projectName = null;
|
|
482
489
|
}
|
|
483
490
|
}
|
|
484
491
|
else {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
result.taskName = task?.name ?? null;
|
|
488
|
-
if (options?.includeProject) {
|
|
489
|
-
const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
|
|
490
|
-
result.projectId = project?.id;
|
|
491
|
-
result.projectName = project?.name ?? null;
|
|
492
|
-
}
|
|
493
|
-
else {
|
|
494
|
-
result.projectName = null;
|
|
495
|
-
}
|
|
492
|
+
result.taskName = null;
|
|
493
|
+
result.projectName = null;
|
|
496
494
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
+
}));
|
|
500
511
|
}
|
|
501
512
|
// ============================================================================
|
|
502
513
|
// Item Queries (Rich Filtering)
|
|
@@ -561,12 +572,11 @@ export class LPClient {
|
|
|
561
572
|
}
|
|
562
573
|
const query = filters.length > 0 ? `?${joinFilters(...filters)}` : '';
|
|
563
574
|
const baseUrl = this.workspaceUrl(`items/v1${query}`);
|
|
564
|
-
|
|
575
|
+
return paginatedFetch({
|
|
565
576
|
fetchFn: (url) => this.fetch(url),
|
|
566
577
|
baseUrl,
|
|
567
578
|
transform: (data) => data.map(transformItem),
|
|
568
579
|
});
|
|
569
|
-
return error ? { error } : { items: results };
|
|
570
580
|
}
|
|
571
581
|
/**
|
|
572
582
|
* Get direct children of an item
|
|
@@ -601,11 +611,11 @@ export class LPClient {
|
|
|
601
611
|
async getWorkspaceTree() {
|
|
602
612
|
return this.cached('tree', this.cacheTtl.treeTtl, async () => {
|
|
603
613
|
// Fetch all workspace items in paginated calls
|
|
604
|
-
const
|
|
605
|
-
if (
|
|
606
|
-
return
|
|
607
|
-
const tree = buildTree(
|
|
608
|
-
return
|
|
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);
|
|
609
619
|
});
|
|
610
620
|
}
|
|
611
621
|
/**
|
|
@@ -620,9 +630,10 @@ export class LPClient {
|
|
|
620
630
|
* @param memberId - The member ID to get work for
|
|
621
631
|
*/
|
|
622
632
|
async getMyWork(memberId) {
|
|
623
|
-
const
|
|
624
|
-
if (
|
|
625
|
-
return
|
|
633
|
+
const treeResult = await this.getWorkspaceTree();
|
|
634
|
+
if (!treeResult.ok)
|
|
635
|
+
return err(treeResult.error, treeResult.status);
|
|
636
|
+
const tree = treeResult.data;
|
|
626
637
|
const assignments = [];
|
|
627
638
|
for (const node of tree.byId.values()) {
|
|
628
639
|
if (node.itemType !== 'Assignment' || node.userId !== memberId)
|
|
@@ -646,7 +657,7 @@ export class LPClient {
|
|
|
646
657
|
}
|
|
647
658
|
assignments.push(result);
|
|
648
659
|
}
|
|
649
|
-
return { assignments, treeItemCount: tree.itemCount };
|
|
660
|
+
return ok({ assignments, treeItemCount: tree.itemCount });
|
|
650
661
|
}
|
|
651
662
|
// ============================================================================
|
|
652
663
|
// Cost Codes
|
|
@@ -657,7 +668,7 @@ export class LPClient {
|
|
|
657
668
|
async getCostCodes() {
|
|
658
669
|
return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
|
|
659
670
|
const baseUrl = this.workspaceUrl('cost-codes/v1');
|
|
660
|
-
|
|
671
|
+
return paginatedFetch({
|
|
661
672
|
fetchFn: (url) => this.fetch(url),
|
|
662
673
|
baseUrl,
|
|
663
674
|
transform: (data) => data.map(cc => ({
|
|
@@ -666,7 +677,6 @@ export class LPClient {
|
|
|
666
677
|
billable: cc.billable,
|
|
667
678
|
})),
|
|
668
679
|
});
|
|
669
|
-
return error ? { error } : { costCodes: results };
|
|
670
680
|
});
|
|
671
681
|
}
|
|
672
682
|
// ============================================================================
|
|
@@ -695,15 +705,11 @@ export class LPClient {
|
|
|
695
705
|
if (note) {
|
|
696
706
|
body.note = note;
|
|
697
707
|
}
|
|
698
|
-
|
|
708
|
+
return this.fetchAndParseMutation(url, async (r) => {
|
|
699
709
|
this.cache?.invalidate('timesheet:');
|
|
700
710
|
const result = await r.json();
|
|
701
711
|
return result.id;
|
|
702
712
|
}, { method: 'POST', body });
|
|
703
|
-
if (error) {
|
|
704
|
-
return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
|
|
705
|
-
}
|
|
706
|
-
return { success: true, entryId: data };
|
|
707
713
|
}
|
|
708
714
|
/**
|
|
709
715
|
* Get timesheet entries for one or more dates
|
|
@@ -730,7 +736,7 @@ export class LPClient {
|
|
|
730
736
|
if (itemId) {
|
|
731
737
|
baseUrl += `&${filterIs('itemId', itemId)}`;
|
|
732
738
|
}
|
|
733
|
-
|
|
739
|
+
return paginatedFetch({
|
|
734
740
|
fetchFn: (url) => this.fetch(url),
|
|
735
741
|
baseUrl,
|
|
736
742
|
transform: (data) => data.map(entry => ({
|
|
@@ -743,7 +749,6 @@ export class LPClient {
|
|
|
743
749
|
userId: entry.userId,
|
|
744
750
|
})),
|
|
745
751
|
});
|
|
746
|
-
return error ? { error } : { entries: results };
|
|
747
752
|
});
|
|
748
753
|
}
|
|
749
754
|
/**
|
|
@@ -779,13 +784,10 @@ export class LPClient {
|
|
|
779
784
|
note: updates.note ?? '',
|
|
780
785
|
userId: existingEntry.userId,
|
|
781
786
|
};
|
|
782
|
-
|
|
787
|
+
return this.fetchAndParseMutation(url, async () => {
|
|
783
788
|
this.cache?.invalidate('timesheet:');
|
|
789
|
+
return entryId;
|
|
784
790
|
}, { method: 'PUT', body });
|
|
785
|
-
if (error) {
|
|
786
|
-
return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
|
|
787
|
-
}
|
|
788
|
-
return { success: true, entryId };
|
|
789
791
|
}
|
|
790
792
|
/**
|
|
791
793
|
* Create or update a timesheet entry (upsert)
|
|
@@ -824,14 +826,14 @@ export class LPClient {
|
|
|
824
826
|
async upsertTimesheetEntry(entry, options = {}) {
|
|
825
827
|
const { accumulate = true } = options;
|
|
826
828
|
// Fetch existing entries for this date/item first
|
|
827
|
-
const
|
|
828
|
-
if (
|
|
829
|
-
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 };
|
|
830
832
|
}
|
|
831
833
|
// Find matching entry
|
|
832
834
|
// If no costCodeId specified, match any entry (LP uses assignment's default)
|
|
833
835
|
// If costCodeId specified, match exactly
|
|
834
|
-
const existingEntry =
|
|
836
|
+
const existingEntry = fetchResult.data?.find((e) => {
|
|
835
837
|
if (entry.costCodeId === undefined || entry.costCodeId === null) {
|
|
836
838
|
return true;
|
|
837
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
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
* const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
|
|
11
11
|
*
|
|
12
12
|
* // Validate credentials
|
|
13
|
-
* await client.validateToken();
|
|
13
|
+
* const result = await client.validateToken();
|
|
14
|
+
* if (!result.ok) { console.error(result.error); }
|
|
14
15
|
*
|
|
15
16
|
* // Get workspaces
|
|
16
|
-
* const
|
|
17
|
+
* const wsResult = await client.getWorkspaces();
|
|
18
|
+
* const workspaces = wsResult.data;
|
|
17
19
|
*
|
|
18
20
|
* // Resolve task to assignment
|
|
19
21
|
* const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
@@ -28,11 +30,12 @@
|
|
|
28
30
|
*/
|
|
29
31
|
export { LPClient } from './client.js';
|
|
30
32
|
export { resolveTaskToAssignment } from './workflows.js';
|
|
31
|
-
export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution,
|
|
33
|
+
export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPUpsertOptions, LPAssignmentWithContext, LPFindItemsOptions, LPTreeNode, LPWorkspaceTree, } from './types.js';
|
|
34
|
+
export { ok, err, getErrorMessage } from '@markwharton/api-core';
|
|
35
|
+
export type { Result, RetryConfig } from '@markwharton/api-core';
|
|
32
36
|
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIsNot, filterIn, filterGt, filterLt, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
|
|
33
37
|
export type { PaginateOptions } from './utils.js';
|
|
34
38
|
export { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree, } from './tree.js';
|
|
35
|
-
export { getErrorMessage } from '@markwharton/api-core';
|
|
36
39
|
export { LP_API_BASE } from './constants.js';
|
|
37
40
|
export { LPError, parseLPErrorResponse } from './errors.js';
|
|
38
41
|
export type { LPParsedError } from './errors.js';
|
package/dist/index.js
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
* const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
|
|
11
11
|
*
|
|
12
12
|
* // Validate credentials
|
|
13
|
-
* await client.validateToken();
|
|
13
|
+
* const result = await client.validateToken();
|
|
14
|
+
* if (!result.ok) { console.error(result.error); }
|
|
14
15
|
*
|
|
15
16
|
* // Get workspaces
|
|
16
|
-
* const
|
|
17
|
+
* const wsResult = await client.getWorkspaces();
|
|
18
|
+
* const workspaces = wsResult.data;
|
|
17
19
|
*
|
|
18
20
|
* // Resolve task to assignment
|
|
19
21
|
* const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
@@ -30,11 +32,12 @@
|
|
|
30
32
|
export { LPClient } from './client.js';
|
|
31
33
|
// Workflows
|
|
32
34
|
export { resolveTaskToAssignment } from './workflows.js';
|
|
35
|
+
// Re-export Result from api-core
|
|
36
|
+
export { ok, err, getErrorMessage } from '@markwharton/api-core';
|
|
33
37
|
// Utilities
|
|
34
38
|
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIsNot, filterIn, filterGt, filterLt, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
|
|
35
39
|
// Tree utilities
|
|
36
40
|
export { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree, } from './tree.js';
|
|
37
|
-
export { getErrorMessage } from '@markwharton/api-core';
|
|
38
41
|
// Constants
|
|
39
42
|
export { LP_API_BASE } from './constants.js';
|
|
40
43
|
// Errors
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* These types define the data structures used when interacting with
|
|
5
5
|
* the LiquidPlanner API.
|
|
6
6
|
*/
|
|
7
|
+
import type { Result, RetryConfig } from '@markwharton/api-core';
|
|
7
8
|
/**
|
|
8
9
|
* LiquidPlanner item types in the hierarchy
|
|
9
10
|
*/
|
|
@@ -192,18 +193,9 @@ export interface LPCacheConfig {
|
|
|
192
193
|
/**
|
|
193
194
|
* Retry configuration for LPClient
|
|
194
195
|
*
|
|
195
|
-
*
|
|
196
|
-
* (HTTP 429 Too Many Requests, 503 Service Unavailable).
|
|
197
|
-
* Uses exponential backoff with optional Retry-After header support.
|
|
196
|
+
* @deprecated Use RetryConfig from @markwharton/api-core instead
|
|
198
197
|
*/
|
|
199
|
-
export
|
|
200
|
-
/** Maximum number of retry attempts (default: 3) */
|
|
201
|
-
maxRetries?: number;
|
|
202
|
-
/** Initial delay in milliseconds before first retry (default: 1000) */
|
|
203
|
-
initialDelayMs?: number;
|
|
204
|
-
/** Maximum delay cap in milliseconds (default: 10000) */
|
|
205
|
-
maxDelayMs?: number;
|
|
206
|
-
}
|
|
198
|
+
export type LPRetryConfig = RetryConfig;
|
|
207
199
|
/**
|
|
208
200
|
* LiquidPlanner configuration for API access
|
|
209
201
|
*/
|
|
@@ -227,16 +219,10 @@ export interface LPConfig {
|
|
|
227
219
|
}
|
|
228
220
|
/**
|
|
229
221
|
* Result of a timesheet sync operation
|
|
222
|
+
*
|
|
223
|
+
* Extends Result<number> where data is the entry ID.
|
|
230
224
|
*/
|
|
231
|
-
export interface LPSyncResult {
|
|
232
|
-
/** Whether the operation succeeded */
|
|
233
|
-
success: boolean;
|
|
234
|
-
/** ID of the created entry (if successful) */
|
|
235
|
-
entryId?: number;
|
|
236
|
-
/** Error message (if failed) */
|
|
237
|
-
error?: string;
|
|
238
|
-
/** HTTP status code (if failed) - useful for detecting rate limits (429) */
|
|
239
|
-
statusCode?: number;
|
|
225
|
+
export interface LPSyncResult extends Result<number> {
|
|
240
226
|
/** Whether the error was due to a duplicate entry */
|
|
241
227
|
isDuplicate?: boolean;
|
|
242
228
|
}
|
|
@@ -264,15 +250,6 @@ export interface LPTimesheetEntryWithId extends LPTimesheetEntry {
|
|
|
264
250
|
/** User ID who logged the time */
|
|
265
251
|
userId?: number;
|
|
266
252
|
}
|
|
267
|
-
/**
|
|
268
|
-
* Generic result wrapper for LP operations
|
|
269
|
-
*/
|
|
270
|
-
export interface LPResult<T> {
|
|
271
|
-
/** The data if successful */
|
|
272
|
-
data?: T;
|
|
273
|
-
/** Error message if failed */
|
|
274
|
-
error?: string;
|
|
275
|
-
}
|
|
276
253
|
/**
|
|
277
254
|
* Options for upsert timesheet entry operation
|
|
278
255
|
*/
|
|
@@ -302,19 +279,6 @@ export interface LPAssignmentWithContext extends LPItem {
|
|
|
302
279
|
/** Formatted hierarchy path like "Project A › Subfolder B" (undefined if not requested) */
|
|
303
280
|
hierarchyPath?: string;
|
|
304
281
|
}
|
|
305
|
-
/**
|
|
306
|
-
* Structured error information from LP API
|
|
307
|
-
*
|
|
308
|
-
* Preserves HTTP status code for proper error handling (e.g., 429 rate limits).
|
|
309
|
-
*/
|
|
310
|
-
export interface LPErrorInfo {
|
|
311
|
-
/** Human-readable error message */
|
|
312
|
-
message: string;
|
|
313
|
-
/** HTTP status code from the response */
|
|
314
|
-
statusCode: number;
|
|
315
|
-
/** Whether this error indicates a duplicate entry */
|
|
316
|
-
isDuplicate?: boolean;
|
|
317
|
-
}
|
|
318
282
|
/**
|
|
319
283
|
* Options for querying items with LP API filters
|
|
320
284
|
*
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LiquidPlanner Utility Functions
|
|
3
3
|
*/
|
|
4
|
-
import type { LPItemType
|
|
4
|
+
import type { LPItemType } from './types.js';
|
|
5
|
+
import type { Result } from '@markwharton/api-core';
|
|
5
6
|
/**
|
|
6
7
|
* Build a URL-encoded filter for LP API: field[is]="value"
|
|
7
8
|
*/
|
|
@@ -56,10 +57,7 @@ export interface PaginateOptions<TRaw, TResult> {
|
|
|
56
57
|
*
|
|
57
58
|
* Handles the continuation token pattern used by LP API.
|
|
58
59
|
*/
|
|
59
|
-
export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<
|
|
60
|
-
results?: TResult[];
|
|
61
|
-
error?: LPErrorInfo;
|
|
62
|
-
}>;
|
|
60
|
+
export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<Result<TResult[]>>;
|
|
63
61
|
/**
|
|
64
62
|
* Convert decimal hours to minutes
|
|
65
63
|
*
|
package/dist/utils.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* LiquidPlanner Utility Functions
|
|
3
3
|
*/
|
|
4
4
|
import { parseLPErrorResponse } from './errors.js';
|
|
5
|
-
import { getErrorMessage } from '@markwharton/api-core';
|
|
5
|
+
import { getErrorMessage, err } from '@markwharton/api-core';
|
|
6
6
|
// ============================================================================
|
|
7
7
|
// LP API Filter Builders
|
|
8
8
|
// ============================================================================
|
|
@@ -88,8 +88,8 @@ export async function paginatedFetch(options) {
|
|
|
88
88
|
const response = await fetchFn(url);
|
|
89
89
|
if (!response.ok) {
|
|
90
90
|
const errorText = await response.text();
|
|
91
|
-
const { message
|
|
92
|
-
return
|
|
91
|
+
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
92
|
+
return err(message, response.status);
|
|
93
93
|
}
|
|
94
94
|
const result = await response.json();
|
|
95
95
|
const rawData = result.data || [];
|
|
@@ -98,11 +98,11 @@ export async function paginatedFetch(options) {
|
|
|
98
98
|
allResults.push(...pageResults);
|
|
99
99
|
continuationToken = result.continuationToken;
|
|
100
100
|
} while (continuationToken);
|
|
101
|
-
return {
|
|
101
|
+
return { ok: true, data: allResults };
|
|
102
102
|
}
|
|
103
103
|
catch (error) {
|
|
104
104
|
// Network errors or JSON parse errors don't have HTTP status codes
|
|
105
|
-
return
|
|
105
|
+
return err(getErrorMessage(error), 0);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
/**
|
package/dist/workflows.js
CHANGED
|
@@ -37,14 +37,15 @@
|
|
|
37
37
|
*/
|
|
38
38
|
export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
|
|
39
39
|
// Step 1: Fetch the item
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
40
|
+
const itemResult = await client.getItem(itemId);
|
|
41
|
+
if (!itemResult.ok || !itemResult.data) {
|
|
42
42
|
return {
|
|
43
43
|
inputItem: { id: itemId, name: null, itemType: 'Task' },
|
|
44
44
|
assignmentId: 0,
|
|
45
|
-
error:
|
|
45
|
+
error: itemResult.error || 'Item not found',
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
+
const item = itemResult.data;
|
|
48
49
|
// Step 2: Check item type and resolve accordingly
|
|
49
50
|
switch (item.itemType) {
|
|
50
51
|
case 'Assignment':
|
|
@@ -57,14 +58,15 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
|
|
|
57
58
|
};
|
|
58
59
|
case 'Task': {
|
|
59
60
|
// Find assignments under this task
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
61
|
+
const assignResult = await client.findAssignments(item.id);
|
|
62
|
+
if (!assignResult.ok) {
|
|
62
63
|
return {
|
|
63
64
|
inputItem: item,
|
|
64
65
|
assignmentId: 0,
|
|
65
|
-
error: `Failed to find assignments: ${
|
|
66
|
+
error: `Failed to find assignments: ${assignResult.error}`,
|
|
66
67
|
};
|
|
67
68
|
}
|
|
69
|
+
const assignments = assignResult.data;
|
|
68
70
|
if (!assignments || assignments.length === 0) {
|
|
69
71
|
return {
|
|
70
72
|
inputItem: item,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markwharton/liquidplanner",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "LiquidPlanner API client for timesheet integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"clean": "rm -rf dist"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@markwharton/api-core": "^1.
|
|
19
|
+
"@markwharton/api-core": "^1.1.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^20.10.0",
|
package/dist/cache.d.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Simple in-memory TTL cache with request coalescing
|
|
3
|
-
*
|
|
4
|
-
* Provides per-instance memoization for LPClient API responses.
|
|
5
|
-
* In serverless environments (Azure Functions, Static Web Apps),
|
|
6
|
-
* module-level state persists across warm invocations within the
|
|
7
|
-
* same instance — this cache leverages that behavior.
|
|
8
|
-
*
|
|
9
|
-
* Request coalescing: when multiple concurrent callers request the
|
|
10
|
-
* same expired key, only one factory call is made. All callers
|
|
11
|
-
* receive the same resolved value (or the same rejection).
|
|
12
|
-
*
|
|
13
|
-
* Not a distributed cache: each instance has its own cache.
|
|
14
|
-
* Cold starts and instance recycling naturally clear stale data.
|
|
15
|
-
*/
|
|
16
|
-
export declare class TTLCache {
|
|
17
|
-
private store;
|
|
18
|
-
private inflight;
|
|
19
|
-
/**
|
|
20
|
-
* Get a cached value, or call the factory to populate it.
|
|
21
|
-
*
|
|
22
|
-
* If a factory call is already in progress for this key,
|
|
23
|
-
* returns the existing promise instead of starting a duplicate.
|
|
24
|
-
*
|
|
25
|
-
* @param key - Cache key
|
|
26
|
-
* @param ttlMs - Time-to-live in milliseconds
|
|
27
|
-
* @param factory - Async function to produce the value on cache miss
|
|
28
|
-
*/
|
|
29
|
-
get<T>(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T>;
|
|
30
|
-
/**
|
|
31
|
-
* Invalidate cache entries matching a key prefix.
|
|
32
|
-
*
|
|
33
|
-
* Also cancels any in-flight requests for matching keys,
|
|
34
|
-
* so subsequent calls will start fresh factory invocations.
|
|
35
|
-
*
|
|
36
|
-
* Example: invalidate('timesheet:') clears all timesheet entries.
|
|
37
|
-
*/
|
|
38
|
-
invalidate(prefix: string): void;
|
|
39
|
-
/**
|
|
40
|
-
* Clear all cached data and in-flight requests.
|
|
41
|
-
*/
|
|
42
|
-
clear(): void;
|
|
43
|
-
}
|
package/dist/cache.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Simple in-memory TTL cache with request coalescing
|
|
3
|
-
*
|
|
4
|
-
* Provides per-instance memoization for LPClient API responses.
|
|
5
|
-
* In serverless environments (Azure Functions, Static Web Apps),
|
|
6
|
-
* module-level state persists across warm invocations within the
|
|
7
|
-
* same instance — this cache leverages that behavior.
|
|
8
|
-
*
|
|
9
|
-
* Request coalescing: when multiple concurrent callers request the
|
|
10
|
-
* same expired key, only one factory call is made. All callers
|
|
11
|
-
* receive the same resolved value (or the same rejection).
|
|
12
|
-
*
|
|
13
|
-
* Not a distributed cache: each instance has its own cache.
|
|
14
|
-
* Cold starts and instance recycling naturally clear stale data.
|
|
15
|
-
*/
|
|
16
|
-
export class TTLCache {
|
|
17
|
-
constructor() {
|
|
18
|
-
this.store = new Map();
|
|
19
|
-
this.inflight = new Map();
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Get a cached value, or call the factory to populate it.
|
|
23
|
-
*
|
|
24
|
-
* If a factory call is already in progress for this key,
|
|
25
|
-
* returns the existing promise instead of starting a duplicate.
|
|
26
|
-
*
|
|
27
|
-
* @param key - Cache key
|
|
28
|
-
* @param ttlMs - Time-to-live in milliseconds
|
|
29
|
-
* @param factory - Async function to produce the value on cache miss
|
|
30
|
-
*/
|
|
31
|
-
async get(key, ttlMs, factory) {
|
|
32
|
-
const existing = this.store.get(key);
|
|
33
|
-
if (existing && existing.expiresAt > Date.now()) {
|
|
34
|
-
return existing.data;
|
|
35
|
-
}
|
|
36
|
-
const pending = this.inflight.get(key);
|
|
37
|
-
if (pending) {
|
|
38
|
-
return pending;
|
|
39
|
-
}
|
|
40
|
-
const promise = factory().then((data) => {
|
|
41
|
-
this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
|
42
|
-
this.inflight.delete(key);
|
|
43
|
-
return data;
|
|
44
|
-
}, (err) => {
|
|
45
|
-
this.inflight.delete(key);
|
|
46
|
-
throw err;
|
|
47
|
-
});
|
|
48
|
-
this.inflight.set(key, promise);
|
|
49
|
-
return promise;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Invalidate cache entries matching a key prefix.
|
|
53
|
-
*
|
|
54
|
-
* Also cancels any in-flight requests for matching keys,
|
|
55
|
-
* so subsequent calls will start fresh factory invocations.
|
|
56
|
-
*
|
|
57
|
-
* Example: invalidate('timesheet:') clears all timesheet entries.
|
|
58
|
-
*/
|
|
59
|
-
invalidate(prefix) {
|
|
60
|
-
for (const key of this.store.keys()) {
|
|
61
|
-
if (key.startsWith(prefix)) {
|
|
62
|
-
this.store.delete(key);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
for (const key of this.inflight.keys()) {
|
|
66
|
-
if (key.startsWith(prefix)) {
|
|
67
|
-
this.inflight.delete(key);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Clear all cached data and in-flight requests.
|
|
73
|
-
*/
|
|
74
|
-
clear() {
|
|
75
|
-
this.store.clear();
|
|
76
|
-
this.inflight.clear();
|
|
77
|
-
}
|
|
78
|
-
}
|