@markwharton/liquidplanner 1.8.2 → 1.9.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 +135 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +82 -124
- package/dist/errors.d.ts +0 -4
- package/dist/errors.js +0 -11
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/utils.d.ts +0 -13
- package/dist/utils.js +2 -25
- package/package.json +7 -1
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @markwharton/liquidplanner
|
|
2
|
+
|
|
3
|
+
LiquidPlanner API client for timesheet integration.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @markwharton/liquidplanner
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { LPClient, resolveTaskToAssignment } from '@markwharton/liquidplanner';
|
|
15
|
+
|
|
16
|
+
const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
|
|
17
|
+
|
|
18
|
+
// Validate credentials
|
|
19
|
+
await client.validateToken();
|
|
20
|
+
|
|
21
|
+
// Get workspaces
|
|
22
|
+
const { workspaces } = await client.getWorkspaces();
|
|
23
|
+
|
|
24
|
+
// Get workspace members
|
|
25
|
+
const { members } = await client.getWorkspaceMembers();
|
|
26
|
+
|
|
27
|
+
// Get user's assignments (for task picker)
|
|
28
|
+
const { assignments } = await client.getMyAssignments(memberId);
|
|
29
|
+
|
|
30
|
+
// Get assignments with parent task/project names resolved
|
|
31
|
+
const { assignments: enriched } = await client.getMyAssignmentsWithContext(memberId, {
|
|
32
|
+
includeProject: true // optional: also fetch project names
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Get assignments with full hierarchy path
|
|
36
|
+
const { assignments: withHierarchy } = await client.getMyAssignmentsWithContext(memberId, {
|
|
37
|
+
includeHierarchy: true // includes ancestors and hierarchyPath
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Get item ancestors (hierarchy chain)
|
|
41
|
+
const { ancestors } = await client.getItemAncestors(itemId);
|
|
42
|
+
|
|
43
|
+
// Resolve task to assignment
|
|
44
|
+
const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
45
|
+
|
|
46
|
+
// Log time
|
|
47
|
+
await client.createTimesheetEntry({
|
|
48
|
+
date: '2026-01-29',
|
|
49
|
+
itemId: resolution.assignmentId,
|
|
50
|
+
hours: 2.5,
|
|
51
|
+
note: 'Working on feature'
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Query existing entries for a date
|
|
55
|
+
const { entries } = await client.getTimesheetEntries('2026-01-29', assignmentId);
|
|
56
|
+
|
|
57
|
+
// Update an existing entry (accumulate hours)
|
|
58
|
+
await client.updateTimesheetEntry(entryId, existingEntry, {
|
|
59
|
+
hours: existingEntry.hours + 1.5,
|
|
60
|
+
note: 'Additional work'
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API Reference
|
|
65
|
+
|
|
66
|
+
All methods return `{ data?, error? }` result objects rather than throwing exceptions.
|
|
67
|
+
|
|
68
|
+
| Method | Parameters | Returns |
|
|
69
|
+
|--------|-----------|---------|
|
|
70
|
+
| `validateToken()` | — | `{ valid, error? }` |
|
|
71
|
+
| `getWorkspaces()` | — | `{ workspaces?, error? }` |
|
|
72
|
+
| `getWorkspaceMembers()` | — | `{ members?, error? }` |
|
|
73
|
+
| `getItem(itemId)` | `number` | `{ item?, error? }` |
|
|
74
|
+
| `getItems(itemIds)` | `number[]` | `{ items?, error? }` |
|
|
75
|
+
| `getItemAncestors(itemId)` | `number` | `{ ancestors?, error? }` |
|
|
76
|
+
| `findAssignments(taskId)` | `number` | `{ assignments?, error? }` |
|
|
77
|
+
| `getMyAssignments(memberId)` | `number` | `{ assignments?, error? }` |
|
|
78
|
+
| `getMyAssignmentsWithContext(memberId, options?)` | `number, { includeProject?, includeHierarchy? }` | `{ assignments?, error? }` |
|
|
79
|
+
| `getCostCodes()` | — | `{ costCodes?, error? }` |
|
|
80
|
+
| `createTimesheetEntry(entry)` | `LPTimesheetEntry` | `LPSyncResult` |
|
|
81
|
+
| `getTimesheetEntries(date, itemId?)` | `string \| string[], number?` | `{ entries?, error? }` |
|
|
82
|
+
| `updateTimesheetEntry(entryId, existing, updates)` | `number, LPTimesheetEntryWithId, Partial<LPTimesheetEntry>` | `LPSyncResult` |
|
|
83
|
+
| `upsertTimesheetEntry(entry, options?)` | `LPTimesheetEntry, LPUpsertOptions?` | `LPSyncResult` |
|
|
84
|
+
|
|
85
|
+
### Workflow: `resolveTaskToAssignment`
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { resolveTaskToAssignment } from '@markwharton/liquidplanner';
|
|
89
|
+
|
|
90
|
+
const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
91
|
+
// resolution.assignmentId - the assignment ID to use for logging time
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Resolves a Task ID to the correct Assignment ID for time logging. Handles cases where a task has multiple assignments by filtering on member ID.
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const client = new LPClient({
|
|
100
|
+
apiToken: 'your-token', // Required: Bearer token
|
|
101
|
+
workspaceId: 12345, // Required: workspace ID
|
|
102
|
+
baseUrl: '...', // Optional: override API base URL
|
|
103
|
+
onRequest: ({ method, url, description }) => { ... }, // Optional: debug callback
|
|
104
|
+
cache: {}, // Optional: enable TTL caching (defaults below)
|
|
105
|
+
retry: { // Optional: retry on 429/503
|
|
106
|
+
maxRetries: 3,
|
|
107
|
+
initialDelayMs: 1000,
|
|
108
|
+
maxDelayMs: 10000,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Cache TTLs
|
|
114
|
+
|
|
115
|
+
| Cache Key | Default TTL |
|
|
116
|
+
|-----------|-------------|
|
|
117
|
+
| Workspace members | 5 min |
|
|
118
|
+
| Cost codes | 5 min |
|
|
119
|
+
| Items / ancestors | 5 min |
|
|
120
|
+
| Assignments | 2 min |
|
|
121
|
+
| Timesheet entries | 60s |
|
|
122
|
+
|
|
123
|
+
Write operations (`createTimesheetEntry`, `updateTimesheetEntry`) automatically invalidate timesheet cache entries. Call `client.clearCache()` to manually clear all cached data.
|
|
124
|
+
|
|
125
|
+
### Retry
|
|
126
|
+
|
|
127
|
+
Automatically retries on HTTP 429 (Too Many Requests) and 503 (Service Unavailable) with exponential backoff. Respects the `Retry-After` header when present.
|
|
128
|
+
|
|
129
|
+
## Architecture
|
|
130
|
+
|
|
131
|
+
See [ARCHITECTURE.md](./ARCHITECTURE.md) for design decisions, implementation patterns, and known limitations.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/dist/client.d.ts
CHANGED
|
@@ -61,6 +61,14 @@ export declare class LPClient {
|
|
|
61
61
|
* Respects the Retry-After header when present.
|
|
62
62
|
*/
|
|
63
63
|
private fetch;
|
|
64
|
+
/**
|
|
65
|
+
* Build a workspace-scoped URL path.
|
|
66
|
+
*/
|
|
67
|
+
private workspaceUrl;
|
|
68
|
+
/**
|
|
69
|
+
* Fetch a URL and parse the response, with standardized error handling.
|
|
70
|
+
*/
|
|
71
|
+
private fetchAndParse;
|
|
64
72
|
/**
|
|
65
73
|
* Validate the API token by listing workspaces
|
|
66
74
|
*/
|
package/dist/client.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @see https://api-docs.liquidplanner.com/
|
|
8
8
|
*/
|
|
9
|
-
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch,
|
|
10
|
-
import { parseLPErrorResponse
|
|
9
|
+
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
10
|
+
import { parseLPErrorResponse } from './errors.js';
|
|
11
11
|
import { LP_API_BASE } from './constants.js';
|
|
12
|
-
import { TTLCache } from '
|
|
12
|
+
import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
|
|
13
13
|
/** Transform raw API item to LPItem */
|
|
14
14
|
function transformItem(raw) {
|
|
15
15
|
return {
|
|
@@ -104,47 +104,46 @@ export class LPClient {
|
|
|
104
104
|
const { method = 'GET', body, description } = options;
|
|
105
105
|
// Notify listener of request (for debugging)
|
|
106
106
|
this.onRequest?.({ method, url, description });
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
});
|
|
118
|
-
// Check if this is a retryable status
|
|
119
|
-
if (this.retryConfig && (lastResponse.status === 429 || lastResponse.status === 503)) {
|
|
120
|
-
if (attempt >= this.retryConfig.maxRetries) {
|
|
121
|
-
return lastResponse; // Exhausted retries
|
|
122
|
-
}
|
|
123
|
-
// Calculate delay: respect Retry-After header, or use exponential backoff
|
|
124
|
-
let delayMs;
|
|
125
|
-
const retryAfterHeader = lastResponse.headers.get('Retry-After');
|
|
126
|
-
if (retryAfterHeader) {
|
|
127
|
-
const retryAfterSeconds = parseInt(retryAfterHeader, 10);
|
|
128
|
-
delayMs = Number.isFinite(retryAfterSeconds)
|
|
129
|
-
? retryAfterSeconds * 1000
|
|
130
|
-
: this.retryConfig.initialDelayMs * Math.pow(2, attempt);
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
delayMs = this.retryConfig.initialDelayMs * Math.pow(2, attempt);
|
|
134
|
-
}
|
|
135
|
-
delayMs = Math.min(delayMs, this.retryConfig.maxDelayMs);
|
|
136
|
-
// Notify listener of retry (for debugging)
|
|
107
|
+
return fetchWithRetry(url, {
|
|
108
|
+
method,
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: buildAuthHeader(this.apiToken),
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
},
|
|
113
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
114
|
+
}, {
|
|
115
|
+
retry: this.retryConfig,
|
|
116
|
+
onRetry: ({ attempt, maxRetries, delayMs, status }) => {
|
|
137
117
|
this.onRequest?.({
|
|
138
118
|
method,
|
|
139
119
|
url,
|
|
140
|
-
description: `Retry ${attempt
|
|
120
|
+
description: `Retry ${attempt}/${maxRetries} after ${delayMs}ms (HTTP ${status})`,
|
|
141
121
|
});
|
|
142
|
-
|
|
143
|
-
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build a workspace-scoped URL path.
|
|
127
|
+
*/
|
|
128
|
+
workspaceUrl(path) {
|
|
129
|
+
return `${this.baseUrl}/workspaces/${this.workspaceId}/${path}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Fetch a URL and parse the response, with standardized error handling.
|
|
133
|
+
*/
|
|
134
|
+
async fetchAndParse(url, parse, fetchOptions) {
|
|
135
|
+
try {
|
|
136
|
+
const response = await this.fetch(url, fetchOptions);
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const errorText = await response.text();
|
|
139
|
+
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
140
|
+
return { error: { message, statusCode: response.status, isDuplicate } };
|
|
144
141
|
}
|
|
145
|
-
return
|
|
142
|
+
return { data: await parse(response) };
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
146
146
|
}
|
|
147
|
-
return lastResponse;
|
|
148
147
|
}
|
|
149
148
|
// ============================================================================
|
|
150
149
|
// Workspace & Validation
|
|
@@ -173,23 +172,11 @@ export class LPClient {
|
|
|
173
172
|
*/
|
|
174
173
|
async getWorkspaces() {
|
|
175
174
|
const url = `${this.baseUrl}/workspaces/v1`;
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return { error: { message, statusCode: response.status, isDuplicate } };
|
|
182
|
-
}
|
|
183
|
-
const result = await response.json();
|
|
184
|
-
const workspaces = (result.data || []).map(ws => ({
|
|
185
|
-
id: ws.id,
|
|
186
|
-
name: ws.name,
|
|
187
|
-
}));
|
|
188
|
-
return { workspaces };
|
|
189
|
-
}
|
|
190
|
-
catch (error) {
|
|
191
|
-
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
192
|
-
}
|
|
175
|
+
const { data, error } = await this.fetchAndParse(url, async (r) => {
|
|
176
|
+
const result = await r.json();
|
|
177
|
+
return (result.data || []).map(ws => ({ id: ws.id, name: ws.name }));
|
|
178
|
+
});
|
|
179
|
+
return error ? { error } : { workspaces: data };
|
|
193
180
|
}
|
|
194
181
|
// ============================================================================
|
|
195
182
|
// Members
|
|
@@ -199,7 +186,7 @@ export class LPClient {
|
|
|
199
186
|
*/
|
|
200
187
|
async getWorkspaceMembers() {
|
|
201
188
|
return this.cached('members', this.cacheTtl.membersTtl, async () => {
|
|
202
|
-
const baseUrl =
|
|
189
|
+
const baseUrl = this.workspaceUrl('users/v1');
|
|
203
190
|
const { results, error } = await paginatedFetch({
|
|
204
191
|
fetchFn: (url) => this.fetch(url),
|
|
205
192
|
baseUrl,
|
|
@@ -223,23 +210,18 @@ export class LPClient {
|
|
|
223
210
|
*/
|
|
224
211
|
async getItem(itemId) {
|
|
225
212
|
return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
226
|
-
const url =
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
if (!
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return { item: transformItem(result.data[0]) };
|
|
239
|
-
}
|
|
240
|
-
catch (error) {
|
|
241
|
-
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
242
|
-
}
|
|
213
|
+
const url = this.workspaceUrl(`items/v1?${filterIs('id', itemId)}`);
|
|
214
|
+
const { data, error } = await this.fetchAndParse(url, async (r) => {
|
|
215
|
+
const result = await r.json();
|
|
216
|
+
if (!result.data || result.data.length === 0)
|
|
217
|
+
return null;
|
|
218
|
+
return transformItem(result.data[0]);
|
|
219
|
+
});
|
|
220
|
+
if (error)
|
|
221
|
+
return { error };
|
|
222
|
+
if (!data)
|
|
223
|
+
return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
|
|
224
|
+
return { item: data };
|
|
243
225
|
});
|
|
244
226
|
}
|
|
245
227
|
/**
|
|
@@ -253,7 +235,7 @@ export class LPClient {
|
|
|
253
235
|
async getItems(itemIds) {
|
|
254
236
|
if (itemIds.length === 0)
|
|
255
237
|
return { items: [] };
|
|
256
|
-
const baseUrl =
|
|
238
|
+
const baseUrl = this.workspaceUrl(`items/v1?${filterIn('id', itemIds)}`);
|
|
257
239
|
const { results, error } = await paginatedFetch({
|
|
258
240
|
fetchFn: (url) => this.fetch(url),
|
|
259
241
|
baseUrl,
|
|
@@ -271,29 +253,18 @@ export class LPClient {
|
|
|
271
253
|
*/
|
|
272
254
|
async getItemAncestors(itemId) {
|
|
273
255
|
return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
274
|
-
const url =
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
description: `Get ancestors for item ${itemId}`,
|
|
278
|
-
});
|
|
279
|
-
if (!response.ok) {
|
|
280
|
-
const errorText = await response.text();
|
|
281
|
-
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
282
|
-
return { error: { message, statusCode: response.status, isDuplicate } };
|
|
283
|
-
}
|
|
284
|
-
const json = (await response.json());
|
|
256
|
+
const url = this.workspaceUrl(`items/v1/${itemId}/ancestors`);
|
|
257
|
+
const { data, error } = await this.fetchAndParse(url, async (r) => {
|
|
258
|
+
const json = (await r.json());
|
|
285
259
|
// Handle both { data: [...] } and direct array responses
|
|
286
260
|
const rawData = Array.isArray(json) ? json : (json.data || []);
|
|
287
|
-
|
|
261
|
+
return rawData.map((a) => ({
|
|
288
262
|
id: a.id,
|
|
289
263
|
name: a.name || null,
|
|
290
264
|
itemType: normalizeItemType(a.itemType),
|
|
291
265
|
})).reverse(); // LP API returns child→root, normalize to root→child
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
catch (error) {
|
|
295
|
-
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
296
|
-
}
|
|
266
|
+
}, { description: `Get ancestors for item ${itemId}` });
|
|
267
|
+
return error ? { error } : { ancestors: data };
|
|
297
268
|
});
|
|
298
269
|
}
|
|
299
270
|
/**
|
|
@@ -301,7 +272,7 @@ export class LPClient {
|
|
|
301
272
|
*/
|
|
302
273
|
async findAssignments(taskId) {
|
|
303
274
|
// parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
|
|
304
|
-
const baseUrl =
|
|
275
|
+
const baseUrl = this.workspaceUrl(`items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`);
|
|
305
276
|
const { results, error } = await paginatedFetch({
|
|
306
277
|
fetchFn: (url) => this.fetch(url),
|
|
307
278
|
baseUrl,
|
|
@@ -317,7 +288,7 @@ export class LPClient {
|
|
|
317
288
|
*/
|
|
318
289
|
async getMyAssignments(memberId) {
|
|
319
290
|
return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
|
|
320
|
-
const baseUrl =
|
|
291
|
+
const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
|
|
321
292
|
const { results, error } = await paginatedFetch({
|
|
322
293
|
fetchFn: (url) => this.fetch(url),
|
|
323
294
|
baseUrl,
|
|
@@ -455,7 +426,7 @@ export class LPClient {
|
|
|
455
426
|
*/
|
|
456
427
|
async getCostCodes() {
|
|
457
428
|
return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
|
|
458
|
-
const baseUrl =
|
|
429
|
+
const baseUrl = this.workspaceUrl('cost-codes/v1');
|
|
459
430
|
const { results, error } = await paginatedFetch({
|
|
460
431
|
fetchFn: (url) => this.fetch(url),
|
|
461
432
|
baseUrl,
|
|
@@ -479,7 +450,7 @@ export class LPClient {
|
|
|
479
450
|
*/
|
|
480
451
|
async createTimesheetEntry(entry) {
|
|
481
452
|
const { date, itemId, hours, costCodeId, note } = entry;
|
|
482
|
-
const url =
|
|
453
|
+
const url = this.workspaceUrl('logged-time-entries/v1');
|
|
483
454
|
// Build request body according to LP Logged Time Entries API
|
|
484
455
|
const body = {
|
|
485
456
|
date,
|
|
@@ -494,21 +465,15 @@ export class LPClient {
|
|
|
494
465
|
if (note) {
|
|
495
466
|
body.note = note;
|
|
496
467
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
// Invalidate cached timesheet entries for this date
|
|
505
|
-
this.cache?.invalidate(`timesheet:`);
|
|
506
|
-
const result = await response.json();
|
|
507
|
-
return { success: true, entryId: result.id };
|
|
508
|
-
}
|
|
509
|
-
catch (error) {
|
|
510
|
-
return { success: false, error: getErrorMessage(error), statusCode: 0 };
|
|
468
|
+
const { data, error } = await this.fetchAndParse(url, async (r) => {
|
|
469
|
+
this.cache?.invalidate('timesheet:');
|
|
470
|
+
const result = await r.json();
|
|
471
|
+
return result.id;
|
|
472
|
+
}, { method: 'POST', body });
|
|
473
|
+
if (error) {
|
|
474
|
+
return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
|
|
511
475
|
}
|
|
476
|
+
return { success: true, entryId: data };
|
|
512
477
|
}
|
|
513
478
|
/**
|
|
514
479
|
* Get timesheet entries for one or more dates
|
|
@@ -530,7 +495,7 @@ export class LPClient {
|
|
|
530
495
|
const cacheKey = itemId ? `timesheet:${sortedKey}:${itemId}` : `timesheet:${sortedKey}`;
|
|
531
496
|
return this.cached(cacheKey, this.cacheTtl.timesheetTtl, async () => {
|
|
532
497
|
// Build query with date[in] filter (supports multiple dates)
|
|
533
|
-
let baseUrl =
|
|
498
|
+
let baseUrl = this.workspaceUrl(`logged-time-entries/v1?${filterIn('date', dates)}`);
|
|
534
499
|
// Optional filter by itemId
|
|
535
500
|
if (itemId) {
|
|
536
501
|
baseUrl += `&${filterIs('itemId', itemId)}`;
|
|
@@ -571,7 +536,7 @@ export class LPClient {
|
|
|
571
536
|
* @param updates - Fields to update (merged with existing)
|
|
572
537
|
*/
|
|
573
538
|
async updateTimesheetEntry(entryId, existingEntry, updates) {
|
|
574
|
-
const url =
|
|
539
|
+
const url = this.workspaceUrl(`logged-time-entries/v1/${entryId}`);
|
|
575
540
|
// PUT requires all fields - merge updates with existing entry
|
|
576
541
|
// IMPORTANT: LP API appends the note field, so only send new notes
|
|
577
542
|
// If no new note, send empty string to avoid re-appending existing notes
|
|
@@ -584,20 +549,13 @@ export class LPClient {
|
|
|
584
549
|
note: updates.note ?? '',
|
|
585
550
|
userId: existingEntry.userId,
|
|
586
551
|
};
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
return { success: false, error: message, statusCode: response.status };
|
|
593
|
-
}
|
|
594
|
-
// Invalidate cached timesheet entries
|
|
595
|
-
this.cache?.invalidate(`timesheet:`);
|
|
596
|
-
return { success: true, entryId };
|
|
597
|
-
}
|
|
598
|
-
catch (error) {
|
|
599
|
-
return { success: false, error: getErrorMessage(error), statusCode: 0 };
|
|
552
|
+
const { error } = await this.fetchAndParse(url, async () => {
|
|
553
|
+
this.cache?.invalidate('timesheet:');
|
|
554
|
+
}, { method: 'PUT', body });
|
|
555
|
+
if (error) {
|
|
556
|
+
return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
|
|
600
557
|
}
|
|
558
|
+
return { success: true, entryId };
|
|
601
559
|
}
|
|
602
560
|
/**
|
|
603
561
|
* Create or update a timesheet entry (upsert)
|
package/dist/errors.d.ts
CHANGED
package/dist/errors.js
CHANGED
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
*/
|
|
9
9
|
/** Error code for duplicate entry errors */
|
|
10
10
|
const LP_ERROR_CODE_DUPLICATE = 'duplicate_value';
|
|
11
|
-
/** Default error message when none is available */
|
|
12
|
-
const DEFAULT_ERROR_MESSAGE = 'Unknown error';
|
|
13
11
|
/**
|
|
14
12
|
* Parse LP API error response text into a human-readable message.
|
|
15
13
|
*
|
|
@@ -59,12 +57,3 @@ export class LPError extends Error {
|
|
|
59
57
|
});
|
|
60
58
|
}
|
|
61
59
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Get a safe error message from any error type
|
|
64
|
-
*/
|
|
65
|
-
export function getErrorMessage(error) {
|
|
66
|
-
if (error instanceof Error) {
|
|
67
|
-
return error.message;
|
|
68
|
-
}
|
|
69
|
-
return DEFAULT_ERROR_MESSAGE;
|
|
70
|
-
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
export { LPClient } from './client.js';
|
|
30
30
|
export { resolveTaskToAssignment } from './workflows.js';
|
|
31
31
|
export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, } from './types.js';
|
|
32
|
-
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch,
|
|
32
|
+
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
33
33
|
export type { PaginateOptions } from './utils.js';
|
|
34
|
+
export { batchMap, getErrorMessage } from '@markwharton/api-core';
|
|
34
35
|
export { LP_API_BASE } from './constants.js';
|
|
35
|
-
export { LPError, parseLPErrorResponse
|
|
36
|
+
export { LPError, parseLPErrorResponse } from './errors.js';
|
|
36
37
|
export type { LPParsedError } from './errors.js';
|
package/dist/index.js
CHANGED
|
@@ -31,8 +31,9 @@ export { LPClient } from './client.js';
|
|
|
31
31
|
// Workflows
|
|
32
32
|
export { resolveTaskToAssignment } from './workflows.js';
|
|
33
33
|
// Utilities
|
|
34
|
-
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch,
|
|
34
|
+
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
35
|
+
export { batchMap, getErrorMessage } from '@markwharton/api-core';
|
|
35
36
|
// Constants
|
|
36
37
|
export { LP_API_BASE } from './constants.js';
|
|
37
38
|
// Errors
|
|
38
|
-
export { LPError, parseLPErrorResponse
|
|
39
|
+
export { LPError, parseLPErrorResponse } from './errors.js';
|
package/dist/utils.d.ts
CHANGED
|
@@ -54,16 +54,3 @@ export declare function normalizeItemType(apiItemType: string): LPItemType;
|
|
|
54
54
|
* Build the Authorization header for LP API requests
|
|
55
55
|
*/
|
|
56
56
|
export declare function buildAuthHeader(apiToken: string): string;
|
|
57
|
-
/**
|
|
58
|
-
* Map over items with bounded concurrency
|
|
59
|
-
*
|
|
60
|
-
* Processes items in batches of `concurrency`, waiting for each batch
|
|
61
|
-
* to complete before starting the next. This prevents overwhelming
|
|
62
|
-
* APIs with too many simultaneous requests.
|
|
63
|
-
*
|
|
64
|
-
* @param items - Array of items to process
|
|
65
|
-
* @param concurrency - Maximum number of concurrent operations
|
|
66
|
-
* @param fn - Async function to apply to each item
|
|
67
|
-
* @returns Array of results in the same order as input items
|
|
68
|
-
*/
|
|
69
|
-
export declare function batchMap<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]>;
|
package/dist/utils.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LiquidPlanner Utility Functions
|
|
3
3
|
*/
|
|
4
|
-
import { parseLPErrorResponse
|
|
4
|
+
import { parseLPErrorResponse } from './errors.js';
|
|
5
|
+
import { getErrorMessage } from '@markwharton/api-core';
|
|
5
6
|
// ============================================================================
|
|
6
7
|
// LP API Filter Builders
|
|
7
8
|
// ============================================================================
|
|
@@ -104,27 +105,3 @@ export function normalizeItemType(apiItemType) {
|
|
|
104
105
|
export function buildAuthHeader(apiToken) {
|
|
105
106
|
return `Bearer ${apiToken}`;
|
|
106
107
|
}
|
|
107
|
-
// ============================================================================
|
|
108
|
-
// Concurrency Helper
|
|
109
|
-
// ============================================================================
|
|
110
|
-
/**
|
|
111
|
-
* Map over items with bounded concurrency
|
|
112
|
-
*
|
|
113
|
-
* Processes items in batches of `concurrency`, waiting for each batch
|
|
114
|
-
* to complete before starting the next. This prevents overwhelming
|
|
115
|
-
* APIs with too many simultaneous requests.
|
|
116
|
-
*
|
|
117
|
-
* @param items - Array of items to process
|
|
118
|
-
* @param concurrency - Maximum number of concurrent operations
|
|
119
|
-
* @param fn - Async function to apply to each item
|
|
120
|
-
* @returns Array of results in the same order as input items
|
|
121
|
-
*/
|
|
122
|
-
export async function batchMap(items, concurrency, fn) {
|
|
123
|
-
const results = [];
|
|
124
|
-
for (let i = 0; i < items.length; i += concurrency) {
|
|
125
|
-
const batch = items.slice(i, i + concurrency);
|
|
126
|
-
const batchResults = await Promise.all(batch.map(fn));
|
|
127
|
-
results.push(...batchResults);
|
|
128
|
-
}
|
|
129
|
-
return results;
|
|
130
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markwharton/liquidplanner",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "LiquidPlanner API client for timesheet integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"clean": "rm -rf dist"
|
|
17
17
|
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@markwharton/api-core": "^1.0.0"
|
|
20
|
+
},
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"@types/node": "^20.10.0",
|
|
20
23
|
"typescript": "^5.3.0"
|
|
@@ -27,6 +30,9 @@
|
|
|
27
30
|
"url": "git+https://github.com/MarkWharton/api-packages.git",
|
|
28
31
|
"directory": "packages/liquidplanner"
|
|
29
32
|
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
30
36
|
"author": "Mark Wharton",
|
|
31
37
|
"license": "MIT",
|
|
32
38
|
"engines": {
|