@markwharton/liquidplanner 1.4.1 → 1.6.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/cache.d.ts +32 -0
- package/dist/cache.js +50 -0
- package/dist/client.d.ts +29 -15
- package/dist/client.js +147 -97
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +40 -0
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +4 -3
- package/dist/workflows.js +2 -2
- package/package.json +1 -1
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory TTL cache
|
|
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
|
+
* Not a distributed cache: each instance has its own cache.
|
|
10
|
+
* Cold starts and instance recycling naturally clear stale data.
|
|
11
|
+
*/
|
|
12
|
+
export declare class TTLCache {
|
|
13
|
+
private store;
|
|
14
|
+
/**
|
|
15
|
+
* Get a cached value, or call the factory to populate it.
|
|
16
|
+
*
|
|
17
|
+
* @param key - Cache key
|
|
18
|
+
* @param ttlMs - Time-to-live in milliseconds
|
|
19
|
+
* @param factory - Async function to produce the value on cache miss
|
|
20
|
+
*/
|
|
21
|
+
get<T>(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T>;
|
|
22
|
+
/**
|
|
23
|
+
* Invalidate cache entries matching a key prefix.
|
|
24
|
+
*
|
|
25
|
+
* Example: invalidate('timesheet:') clears all timesheet entries.
|
|
26
|
+
*/
|
|
27
|
+
invalidate(prefix: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Clear all cached data.
|
|
30
|
+
*/
|
|
31
|
+
clear(): void;
|
|
32
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory TTL cache
|
|
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
|
+
* Not a distributed cache: each instance has its own cache.
|
|
10
|
+
* Cold starts and instance recycling naturally clear stale data.
|
|
11
|
+
*/
|
|
12
|
+
export class TTLCache {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.store = new Map();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get a cached value, or call the factory to populate it.
|
|
18
|
+
*
|
|
19
|
+
* @param key - Cache key
|
|
20
|
+
* @param ttlMs - Time-to-live in milliseconds
|
|
21
|
+
* @param factory - Async function to produce the value on cache miss
|
|
22
|
+
*/
|
|
23
|
+
async get(key, ttlMs, factory) {
|
|
24
|
+
const existing = this.store.get(key);
|
|
25
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
26
|
+
return existing.data;
|
|
27
|
+
}
|
|
28
|
+
const data = await factory();
|
|
29
|
+
this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Invalidate cache entries matching a key prefix.
|
|
34
|
+
*
|
|
35
|
+
* Example: invalidate('timesheet:') clears all timesheet entries.
|
|
36
|
+
*/
|
|
37
|
+
invalidate(prefix) {
|
|
38
|
+
for (const key of this.store.keys()) {
|
|
39
|
+
if (key.startsWith(prefix)) {
|
|
40
|
+
this.store.delete(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Clear all cached data.
|
|
46
|
+
*/
|
|
47
|
+
clear() {
|
|
48
|
+
this.store.clear();
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @see https://api-docs.liquidplanner.com/
|
|
8
8
|
*/
|
|
9
|
-
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor } from './types.js';
|
|
9
|
+
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor, LPErrorInfo } from './types.js';
|
|
10
10
|
/**
|
|
11
11
|
* LiquidPlanner API Client
|
|
12
12
|
*
|
|
@@ -34,7 +34,18 @@ export declare class LPClient {
|
|
|
34
34
|
private readonly workspaceId;
|
|
35
35
|
private readonly baseUrl;
|
|
36
36
|
private readonly onRequest?;
|
|
37
|
+
private readonly cache?;
|
|
38
|
+
private readonly cacheTtl;
|
|
37
39
|
constructor(config: LPConfig);
|
|
40
|
+
/**
|
|
41
|
+
* Route through cache if enabled, otherwise call factory directly.
|
|
42
|
+
*/
|
|
43
|
+
private cached;
|
|
44
|
+
/**
|
|
45
|
+
* Clear all cached API responses.
|
|
46
|
+
* Useful after external changes that the cache wouldn't know about.
|
|
47
|
+
*/
|
|
48
|
+
clearCache(): void;
|
|
38
49
|
/**
|
|
39
50
|
* Make an authenticated request to the LP API
|
|
40
51
|
*/
|
|
@@ -51,21 +62,21 @@ export declare class LPClient {
|
|
|
51
62
|
*/
|
|
52
63
|
getWorkspaces(): Promise<{
|
|
53
64
|
workspaces?: LPWorkspace[];
|
|
54
|
-
error?:
|
|
65
|
+
error?: LPErrorInfo;
|
|
55
66
|
}>;
|
|
56
67
|
/**
|
|
57
68
|
* Get all members in the workspace (with pagination)
|
|
58
69
|
*/
|
|
59
70
|
getWorkspaceMembers(): Promise<{
|
|
60
71
|
members?: LPMember[];
|
|
61
|
-
error?:
|
|
72
|
+
error?: LPErrorInfo;
|
|
62
73
|
}>;
|
|
63
74
|
/**
|
|
64
75
|
* Get a single item by ID
|
|
65
76
|
*/
|
|
66
77
|
getItem(itemId: number): Promise<{
|
|
67
78
|
item?: LPItem;
|
|
68
|
-
error?:
|
|
79
|
+
error?: LPErrorInfo;
|
|
69
80
|
}>;
|
|
70
81
|
/**
|
|
71
82
|
* Get multiple items by ID in a single request (batch fetch)
|
|
@@ -77,7 +88,7 @@ export declare class LPClient {
|
|
|
77
88
|
*/
|
|
78
89
|
getItems(itemIds: number[]): Promise<{
|
|
79
90
|
items?: LPItem[];
|
|
80
|
-
error?:
|
|
91
|
+
error?: LPErrorInfo;
|
|
81
92
|
}>;
|
|
82
93
|
/**
|
|
83
94
|
* Get the ancestry chain for an item
|
|
@@ -89,14 +100,14 @@ export declare class LPClient {
|
|
|
89
100
|
*/
|
|
90
101
|
getItemAncestors(itemId: number): Promise<{
|
|
91
102
|
ancestors?: LPAncestor[];
|
|
92
|
-
error?:
|
|
103
|
+
error?: LPErrorInfo;
|
|
93
104
|
}>;
|
|
94
105
|
/**
|
|
95
106
|
* Find all assignments under a task (with pagination)
|
|
96
107
|
*/
|
|
97
108
|
findAssignments(taskId: number): Promise<{
|
|
98
109
|
assignments?: LPItem[];
|
|
99
|
-
error?:
|
|
110
|
+
error?: LPErrorInfo;
|
|
100
111
|
}>;
|
|
101
112
|
/**
|
|
102
113
|
* Get all assignments for a specific member
|
|
@@ -106,7 +117,7 @@ export declare class LPClient {
|
|
|
106
117
|
*/
|
|
107
118
|
getMyAssignments(memberId: number): Promise<{
|
|
108
119
|
assignments?: LPItem[];
|
|
109
|
-
error?:
|
|
120
|
+
error?: LPErrorInfo;
|
|
110
121
|
}>;
|
|
111
122
|
/**
|
|
112
123
|
* Get assignments for a member with parent task names resolved
|
|
@@ -129,14 +140,14 @@ export declare class LPClient {
|
|
|
129
140
|
includeHierarchy?: boolean;
|
|
130
141
|
}): Promise<{
|
|
131
142
|
assignments?: LPAssignmentWithContext[];
|
|
132
|
-
error?:
|
|
143
|
+
error?: LPErrorInfo;
|
|
133
144
|
}>;
|
|
134
145
|
/**
|
|
135
146
|
* Get all cost codes in the workspace (with pagination)
|
|
136
147
|
*/
|
|
137
148
|
getCostCodes(): Promise<{
|
|
138
149
|
costCodes?: LPCostCode[];
|
|
139
|
-
error?:
|
|
150
|
+
error?: LPErrorInfo;
|
|
140
151
|
}>;
|
|
141
152
|
/**
|
|
142
153
|
* Create a timesheet entry (log time)
|
|
@@ -146,19 +157,22 @@ export declare class LPClient {
|
|
|
146
157
|
*/
|
|
147
158
|
createTimesheetEntry(entry: LPTimesheetEntry): Promise<LPSyncResult>;
|
|
148
159
|
/**
|
|
149
|
-
* Get timesheet entries for
|
|
160
|
+
* Get timesheet entries for one or more dates
|
|
150
161
|
*
|
|
151
162
|
* Uses the Logged Time Entries API to query existing entries.
|
|
152
|
-
*
|
|
163
|
+
* Supports both single date and multi-date queries using LP's date[in] filter.
|
|
164
|
+
*
|
|
165
|
+
* Multi-date queries reduce API calls — e.g., fetching a full week's entries
|
|
166
|
+
* in a single request instead of 7 separate calls.
|
|
153
167
|
*
|
|
154
168
|
* @see https://api-docs.liquidplanner.com/docs/task-status-1
|
|
155
169
|
*
|
|
156
|
-
* @param date - Date in YYYY-MM-DD format
|
|
170
|
+
* @param date - Date(s) in YYYY-MM-DD format (string or array)
|
|
157
171
|
* @param itemId - Optional item ID to filter by
|
|
158
172
|
*/
|
|
159
|
-
getTimesheetEntries(date: string, itemId?: number): Promise<{
|
|
173
|
+
getTimesheetEntries(date: string | string[], itemId?: number): Promise<{
|
|
160
174
|
entries?: LPTimesheetEntryWithId[];
|
|
161
|
-
error?:
|
|
175
|
+
error?: LPErrorInfo;
|
|
162
176
|
}>;
|
|
163
177
|
/**
|
|
164
178
|
* Update an existing timesheet entry
|
package/dist/client.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
10
10
|
import { parseLPErrorResponse, getErrorMessage } from './errors.js';
|
|
11
11
|
import { LP_API_BASE } from './constants.js';
|
|
12
|
+
import { TTLCache } from './cache.js';
|
|
12
13
|
/** Transform raw API item to LPItem */
|
|
13
14
|
function transformItem(raw) {
|
|
14
15
|
return {
|
|
@@ -48,6 +49,33 @@ export class LPClient {
|
|
|
48
49
|
this.workspaceId = config.workspaceId;
|
|
49
50
|
this.baseUrl = config.baseUrl ?? LP_API_BASE;
|
|
50
51
|
this.onRequest = config.onRequest;
|
|
52
|
+
// Initialize cache if configured
|
|
53
|
+
if (config.cache) {
|
|
54
|
+
this.cache = new TTLCache();
|
|
55
|
+
}
|
|
56
|
+
this.cacheTtl = {
|
|
57
|
+
membersTtl: config.cache?.membersTtl ?? 300000,
|
|
58
|
+
costCodesTtl: config.cache?.costCodesTtl ?? 300000,
|
|
59
|
+
timesheetTtl: config.cache?.timesheetTtl ?? 60000,
|
|
60
|
+
assignmentsTtl: config.cache?.assignmentsTtl ?? 120000,
|
|
61
|
+
itemsTtl: config.cache?.itemsTtl ?? 300000,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Route through cache if enabled, otherwise call factory directly.
|
|
66
|
+
*/
|
|
67
|
+
async cached(key, ttlMs, factory) {
|
|
68
|
+
if (this.cache) {
|
|
69
|
+
return this.cache.get(key, ttlMs, factory);
|
|
70
|
+
}
|
|
71
|
+
return factory();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Clear all cached API responses.
|
|
75
|
+
* Useful after external changes that the cache wouldn't know about.
|
|
76
|
+
*/
|
|
77
|
+
clearCache() {
|
|
78
|
+
this.cache?.clear();
|
|
51
79
|
}
|
|
52
80
|
/**
|
|
53
81
|
* Make an authenticated request to the LP API
|
|
@@ -96,8 +124,8 @@ export class LPClient {
|
|
|
96
124
|
const response = await this.fetch(url);
|
|
97
125
|
if (!response.ok) {
|
|
98
126
|
const errorText = await response.text();
|
|
99
|
-
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
100
|
-
return { error: message };
|
|
127
|
+
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
128
|
+
return { error: { message, statusCode: response.status, isDuplicate } };
|
|
101
129
|
}
|
|
102
130
|
const result = await response.json();
|
|
103
131
|
const workspaces = (result.data || []).map(ws => ({
|
|
@@ -107,7 +135,7 @@ export class LPClient {
|
|
|
107
135
|
return { workspaces };
|
|
108
136
|
}
|
|
109
137
|
catch (error) {
|
|
110
|
-
return { error: getErrorMessage(error) };
|
|
138
|
+
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
111
139
|
}
|
|
112
140
|
}
|
|
113
141
|
// ============================================================================
|
|
@@ -117,20 +145,22 @@ export class LPClient {
|
|
|
117
145
|
* Get all members in the workspace (with pagination)
|
|
118
146
|
*/
|
|
119
147
|
async getWorkspaceMembers() {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
return this.cached('members', this.cacheTtl.membersTtl, async () => {
|
|
149
|
+
const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
|
|
150
|
+
const { results, error } = await paginatedFetch({
|
|
151
|
+
fetchFn: (url) => this.fetch(url),
|
|
152
|
+
baseUrl,
|
|
153
|
+
transform: (data) => data.map(m => ({
|
|
154
|
+
id: m.id,
|
|
155
|
+
username: m.username,
|
|
156
|
+
email: m.email,
|
|
157
|
+
firstName: m.firstName,
|
|
158
|
+
lastName: m.lastName,
|
|
159
|
+
userType: m.userType,
|
|
160
|
+
})),
|
|
161
|
+
});
|
|
162
|
+
return error ? { error } : { members: results };
|
|
132
163
|
});
|
|
133
|
-
return error ? { error } : { members: results };
|
|
134
164
|
}
|
|
135
165
|
// ============================================================================
|
|
136
166
|
// Items
|
|
@@ -139,23 +169,25 @@ export class LPClient {
|
|
|
139
169
|
* Get a single item by ID
|
|
140
170
|
*/
|
|
141
171
|
async getItem(itemId) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
172
|
+
return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
173
|
+
const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
|
|
174
|
+
try {
|
|
175
|
+
const response = await this.fetch(url);
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const errorText = await response.text();
|
|
178
|
+
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
179
|
+
return { error: { message, statusCode: response.status, isDuplicate } };
|
|
180
|
+
}
|
|
181
|
+
const result = await response.json();
|
|
182
|
+
if (!result.data || result.data.length === 0) {
|
|
183
|
+
return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
|
|
184
|
+
}
|
|
185
|
+
return { item: transformItem(result.data[0]) };
|
|
149
186
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return { error: `Item ${itemId} not found` };
|
|
187
|
+
catch (error) {
|
|
188
|
+
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
153
189
|
}
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
catch (error) {
|
|
157
|
-
return { error: getErrorMessage(error) };
|
|
158
|
-
}
|
|
190
|
+
});
|
|
159
191
|
}
|
|
160
192
|
/**
|
|
161
193
|
* Get multiple items by ID in a single request (batch fetch)
|
|
@@ -185,29 +217,31 @@ export class LPClient {
|
|
|
185
217
|
* @param itemId - The item ID to get ancestors for
|
|
186
218
|
*/
|
|
187
219
|
async getItemAncestors(itemId) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
220
|
+
return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
|
|
221
|
+
const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1/${itemId}/ancestors`;
|
|
222
|
+
try {
|
|
223
|
+
const response = await this.fetch(url, {
|
|
224
|
+
description: `Get ancestors for item ${itemId}`,
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const errorText = await response.text();
|
|
228
|
+
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
229
|
+
return { error: { message, statusCode: response.status, isDuplicate } };
|
|
230
|
+
}
|
|
231
|
+
const json = (await response.json());
|
|
232
|
+
// Handle both { data: [...] } and direct array responses
|
|
233
|
+
const rawData = Array.isArray(json) ? json : (json.data || []);
|
|
234
|
+
const ancestors = rawData.map((a) => ({
|
|
235
|
+
id: a.id,
|
|
236
|
+
name: a.name || null,
|
|
237
|
+
itemType: normalizeItemType(a.itemType),
|
|
238
|
+
}));
|
|
239
|
+
return { ancestors };
|
|
197
240
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
id: a.id,
|
|
203
|
-
name: a.name || null,
|
|
204
|
-
itemType: normalizeItemType(a.itemType),
|
|
205
|
-
}));
|
|
206
|
-
return { ancestors };
|
|
207
|
-
}
|
|
208
|
-
catch (error) {
|
|
209
|
-
return { error: getErrorMessage(error) };
|
|
210
|
-
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
243
|
+
}
|
|
244
|
+
});
|
|
211
245
|
}
|
|
212
246
|
/**
|
|
213
247
|
* Find all assignments under a task (with pagination)
|
|
@@ -229,14 +263,16 @@ export class LPClient {
|
|
|
229
263
|
* Note: userId is not a supported filter field in the LP API, so we filter client-side.
|
|
230
264
|
*/
|
|
231
265
|
async getMyAssignments(memberId) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
266
|
+
return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
|
|
267
|
+
const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
|
|
268
|
+
const { results, error } = await paginatedFetch({
|
|
269
|
+
fetchFn: (url) => this.fetch(url),
|
|
270
|
+
baseUrl,
|
|
271
|
+
filter: (data) => data.filter(item => item.userId === memberId),
|
|
272
|
+
transform: (data) => data.map(transformItem),
|
|
273
|
+
});
|
|
274
|
+
return error ? { error } : { assignments: results };
|
|
238
275
|
});
|
|
239
|
-
return error ? { error } : { assignments: results };
|
|
240
276
|
}
|
|
241
277
|
/**
|
|
242
278
|
* Get assignments for a member with parent task names resolved
|
|
@@ -365,17 +401,19 @@ export class LPClient {
|
|
|
365
401
|
* Get all cost codes in the workspace (with pagination)
|
|
366
402
|
*/
|
|
367
403
|
async getCostCodes() {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
404
|
+
return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
|
|
405
|
+
const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
|
|
406
|
+
const { results, error } = await paginatedFetch({
|
|
407
|
+
fetchFn: (url) => this.fetch(url),
|
|
408
|
+
baseUrl,
|
|
409
|
+
transform: (data) => data.map(cc => ({
|
|
410
|
+
id: cc.id,
|
|
411
|
+
name: cc.name,
|
|
412
|
+
billable: cc.billable,
|
|
413
|
+
})),
|
|
414
|
+
});
|
|
415
|
+
return error ? { error } : { costCodes: results };
|
|
377
416
|
});
|
|
378
|
-
return error ? { error } : { costCodes: results };
|
|
379
417
|
}
|
|
380
418
|
// ============================================================================
|
|
381
419
|
// Timesheet
|
|
@@ -408,47 +446,57 @@ export class LPClient {
|
|
|
408
446
|
if (!response.ok) {
|
|
409
447
|
const errorText = await response.text();
|
|
410
448
|
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
411
|
-
return { success: false, error: message, isDuplicate };
|
|
449
|
+
return { success: false, error: message, statusCode: response.status, isDuplicate };
|
|
412
450
|
}
|
|
451
|
+
// Invalidate cached timesheet entries for this date
|
|
452
|
+
this.cache?.invalidate(`timesheet:`);
|
|
413
453
|
const result = await response.json();
|
|
414
454
|
return { success: true, entryId: result.id };
|
|
415
455
|
}
|
|
416
456
|
catch (error) {
|
|
417
|
-
return { success: false, error: getErrorMessage(error) };
|
|
457
|
+
return { success: false, error: getErrorMessage(error), statusCode: 0 };
|
|
418
458
|
}
|
|
419
459
|
}
|
|
420
460
|
/**
|
|
421
|
-
* Get timesheet entries for
|
|
461
|
+
* Get timesheet entries for one or more dates
|
|
422
462
|
*
|
|
423
463
|
* Uses the Logged Time Entries API to query existing entries.
|
|
424
|
-
*
|
|
464
|
+
* Supports both single date and multi-date queries using LP's date[in] filter.
|
|
465
|
+
*
|
|
466
|
+
* Multi-date queries reduce API calls — e.g., fetching a full week's entries
|
|
467
|
+
* in a single request instead of 7 separate calls.
|
|
425
468
|
*
|
|
426
469
|
* @see https://api-docs.liquidplanner.com/docs/task-status-1
|
|
427
470
|
*
|
|
428
|
-
* @param date - Date in YYYY-MM-DD format
|
|
471
|
+
* @param date - Date(s) in YYYY-MM-DD format (string or array)
|
|
429
472
|
* @param itemId - Optional item ID to filter by
|
|
430
473
|
*/
|
|
431
474
|
async getTimesheetEntries(date, itemId) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
475
|
+
const dates = Array.isArray(date) ? date : [date];
|
|
476
|
+
const sortedKey = [...dates].sort().join(',');
|
|
477
|
+
const cacheKey = itemId ? `timesheet:${sortedKey}:${itemId}` : `timesheet:${sortedKey}`;
|
|
478
|
+
return this.cached(cacheKey, this.cacheTtl.timesheetTtl, async () => {
|
|
479
|
+
// Build query with date[in] filter (supports multiple dates)
|
|
480
|
+
let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', dates)}`;
|
|
481
|
+
// Optional filter by itemId
|
|
482
|
+
if (itemId) {
|
|
483
|
+
baseUrl += `&${filterIs('itemId', itemId)}`;
|
|
484
|
+
}
|
|
485
|
+
const { results, error } = await paginatedFetch({
|
|
486
|
+
fetchFn: (url) => this.fetch(url),
|
|
487
|
+
baseUrl,
|
|
488
|
+
transform: (data) => data.map(entry => ({
|
|
489
|
+
id: entry.id,
|
|
490
|
+
date: entry.date,
|
|
491
|
+
itemId: entry.itemId,
|
|
492
|
+
hours: entry.loggedEntriesInMinutes / 60,
|
|
493
|
+
costCodeId: entry.costCodeId,
|
|
494
|
+
note: entry.note,
|
|
495
|
+
userId: entry.userId,
|
|
496
|
+
})),
|
|
497
|
+
});
|
|
498
|
+
return error ? { error } : { entries: results };
|
|
450
499
|
});
|
|
451
|
-
return error ? { error } : { entries: results };
|
|
452
500
|
}
|
|
453
501
|
/**
|
|
454
502
|
* Update an existing timesheet entry
|
|
@@ -488,12 +536,14 @@ export class LPClient {
|
|
|
488
536
|
if (!response.ok) {
|
|
489
537
|
const errorText = await response.text();
|
|
490
538
|
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
491
|
-
return { success: false, error: message };
|
|
539
|
+
return { success: false, error: message, statusCode: response.status };
|
|
492
540
|
}
|
|
541
|
+
// Invalidate cached timesheet entries
|
|
542
|
+
this.cache?.invalidate(`timesheet:`);
|
|
493
543
|
return { success: true, entryId };
|
|
494
544
|
}
|
|
495
545
|
catch (error) {
|
|
496
|
-
return { success: false, error: getErrorMessage(error) };
|
|
546
|
+
return { success: false, error: getErrorMessage(error), statusCode: 0 };
|
|
497
547
|
}
|
|
498
548
|
}
|
|
499
549
|
/**
|
|
@@ -535,7 +585,7 @@ export class LPClient {
|
|
|
535
585
|
// Fetch existing entries for this date/item first
|
|
536
586
|
const { entries, error: fetchError } = await this.getTimesheetEntries(entry.date, entry.itemId);
|
|
537
587
|
if (fetchError) {
|
|
538
|
-
return { success: false, error: fetchError };
|
|
588
|
+
return { success: false, error: fetchError.message, statusCode: fetchError.statusCode };
|
|
539
589
|
}
|
|
540
590
|
// Find matching entry
|
|
541
591
|
// If no costCodeId specified, match any entry (LP uses assignment's default)
|
package/dist/index.d.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
export { LPClient } from './client.js';
|
|
30
30
|
export { resolveTaskToAssignment } from './workflows.js';
|
|
31
|
-
export type { LPConfig, LPItemType, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, } from './types.js';
|
|
31
|
+
export type { LPConfig, LPCacheConfig, LPItemType, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, } from './types.js';
|
|
32
32
|
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
33
33
|
export type { PaginateOptions } from './utils.js';
|
|
34
34
|
export { LP_API_BASE } from './constants.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -92,6 +92,29 @@ export interface LPTaskResolution {
|
|
|
92
92
|
/** Error message if resolution failed */
|
|
93
93
|
error?: string;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Cache configuration for LPClient
|
|
97
|
+
*
|
|
98
|
+
* When provided to LPConfig, enables in-memory TTL caching of API responses.
|
|
99
|
+
* All TTL values are in milliseconds. Omit individual TTLs to use defaults.
|
|
100
|
+
*
|
|
101
|
+
* Caching uses a simple in-memory Map that persists across warm invocations
|
|
102
|
+
* in serverless environments (Azure Functions, Static Web Apps). Each instance
|
|
103
|
+
* has its own independent cache — this is per-instance memoization, not a
|
|
104
|
+
* distributed cache. Cold starts and instance recycling naturally clear data.
|
|
105
|
+
*/
|
|
106
|
+
export interface LPCacheConfig {
|
|
107
|
+
/** TTL for workspace members (default: 300000 = 5 min) */
|
|
108
|
+
membersTtl?: number;
|
|
109
|
+
/** TTL for cost codes (default: 300000 = 5 min) */
|
|
110
|
+
costCodesTtl?: number;
|
|
111
|
+
/** TTL for timesheet entries (default: 60000 = 60s) */
|
|
112
|
+
timesheetTtl?: number;
|
|
113
|
+
/** TTL for user assignments (default: 120000 = 2 min) */
|
|
114
|
+
assignmentsTtl?: number;
|
|
115
|
+
/** TTL for items and ancestors (default: 300000 = 5 min) */
|
|
116
|
+
itemsTtl?: number;
|
|
117
|
+
}
|
|
95
118
|
/**
|
|
96
119
|
* LiquidPlanner configuration for API access
|
|
97
120
|
*/
|
|
@@ -108,6 +131,8 @@ export interface LPConfig {
|
|
|
108
131
|
url: string;
|
|
109
132
|
description?: string;
|
|
110
133
|
}) => void;
|
|
134
|
+
/** Enable caching with optional TTL overrides. Omit to disable caching. */
|
|
135
|
+
cache?: LPCacheConfig;
|
|
111
136
|
}
|
|
112
137
|
/**
|
|
113
138
|
* Result of a timesheet sync operation
|
|
@@ -119,6 +144,8 @@ export interface LPSyncResult {
|
|
|
119
144
|
entryId?: number;
|
|
120
145
|
/** Error message (if failed) */
|
|
121
146
|
error?: string;
|
|
147
|
+
/** HTTP status code (if failed) - useful for detecting rate limits (429) */
|
|
148
|
+
statusCode?: number;
|
|
122
149
|
/** Whether the error was due to a duplicate entry */
|
|
123
150
|
isDuplicate?: boolean;
|
|
124
151
|
}
|
|
@@ -184,3 +211,16 @@ export interface LPAssignmentWithContext extends LPItem {
|
|
|
184
211
|
/** Formatted hierarchy path like "Project A › Subfolder B" (undefined if not requested) */
|
|
185
212
|
hierarchyPath?: string;
|
|
186
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Structured error information from LP API
|
|
216
|
+
*
|
|
217
|
+
* Preserves HTTP status code for proper error handling (e.g., 429 rate limits).
|
|
218
|
+
*/
|
|
219
|
+
export interface LPErrorInfo {
|
|
220
|
+
/** Human-readable error message */
|
|
221
|
+
message: string;
|
|
222
|
+
/** HTTP status code from the response */
|
|
223
|
+
statusCode: number;
|
|
224
|
+
/** Whether this error indicates a duplicate entry */
|
|
225
|
+
isDuplicate?: boolean;
|
|
226
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LiquidPlanner Utility Functions
|
|
3
3
|
*/
|
|
4
|
-
import type { LPItemType } from './types.js';
|
|
4
|
+
import type { LPItemType, LPErrorInfo } from './types.js';
|
|
5
5
|
/**
|
|
6
6
|
* Build a URL-encoded filter for LP API: field[is]="value"
|
|
7
7
|
*/
|
|
@@ -30,7 +30,7 @@ export interface PaginateOptions<TRaw, TResult> {
|
|
|
30
30
|
*/
|
|
31
31
|
export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<{
|
|
32
32
|
results?: TResult[];
|
|
33
|
-
error?:
|
|
33
|
+
error?: LPErrorInfo;
|
|
34
34
|
}>;
|
|
35
35
|
/**
|
|
36
36
|
* Convert decimal hours to minutes
|
package/dist/utils.js
CHANGED
|
@@ -36,8 +36,8 @@ export async function paginatedFetch(options) {
|
|
|
36
36
|
const response = await fetchFn(url);
|
|
37
37
|
if (!response.ok) {
|
|
38
38
|
const errorText = await response.text();
|
|
39
|
-
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
40
|
-
return { error: message };
|
|
39
|
+
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
40
|
+
return { error: { message, statusCode: response.status, isDuplicate } };
|
|
41
41
|
}
|
|
42
42
|
const result = await response.json();
|
|
43
43
|
const rawData = result.data || [];
|
|
@@ -49,7 +49,8 @@ export async function paginatedFetch(options) {
|
|
|
49
49
|
return { results: allResults };
|
|
50
50
|
}
|
|
51
51
|
catch (error) {
|
|
52
|
-
|
|
52
|
+
// Network errors or JSON parse errors don't have HTTP status codes
|
|
53
|
+
return { error: { message: getErrorMessage(error), statusCode: 0 } };
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
/**
|
package/dist/workflows.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
|
|
|
42
42
|
return {
|
|
43
43
|
inputItem: { id: itemId, name: null, itemType: 'Task' },
|
|
44
44
|
assignmentId: 0,
|
|
45
|
-
error: fetchError || 'Item not found',
|
|
45
|
+
error: fetchError?.message || 'Item not found',
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
// Step 2: Check item type and resolve accordingly
|
|
@@ -62,7 +62,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
|
|
|
62
62
|
return {
|
|
63
63
|
inputItem: item,
|
|
64
64
|
assignmentId: 0,
|
|
65
|
-
error: `Failed to find assignments: ${assignmentError}`,
|
|
65
|
+
error: `Failed to find assignments: ${assignmentError.message}`,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
if (!assignments || assignments.length === 0) {
|