@markwharton/liquidplanner 1.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 +135 -0
- package/dist/client.js +324 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.js +11 -0
- package/dist/errors.d.ts +48 -0
- package/dist/errors.js +70 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +38 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.js +7 -0
- package/dist/utils.d.ts +56 -0
- package/dist/utils.js +99 -0
- package/dist/workflows.d.ts +40 -0
- package/dist/workflows.js +136 -0
- package/package.json +35 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner API Client
|
|
3
|
+
*
|
|
4
|
+
* Provides methods for interacting with the LiquidPlanner API.
|
|
5
|
+
* Uses Bearer Token authentication.
|
|
6
|
+
*
|
|
7
|
+
* @see https://api-docs.liquidplanner.com/
|
|
8
|
+
*/
|
|
9
|
+
import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId } from './types.js';
|
|
10
|
+
/**
|
|
11
|
+
* LiquidPlanner API Client
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
|
|
16
|
+
*
|
|
17
|
+
* // Validate credentials
|
|
18
|
+
* const validation = await client.validateToken();
|
|
19
|
+
*
|
|
20
|
+
* // Get workspaces
|
|
21
|
+
* const workspaces = await client.getWorkspaces();
|
|
22
|
+
*
|
|
23
|
+
* // Log time
|
|
24
|
+
* await client.createTimesheetEntry({
|
|
25
|
+
* date: '2026-01-29',
|
|
26
|
+
* itemId: 12345,
|
|
27
|
+
* hours: 2.5,
|
|
28
|
+
* note: 'Working on feature'
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare class LPClient {
|
|
33
|
+
private readonly apiToken;
|
|
34
|
+
private readonly workspaceId;
|
|
35
|
+
private readonly baseUrl;
|
|
36
|
+
constructor(config: LPConfig);
|
|
37
|
+
/**
|
|
38
|
+
* Make an authenticated request to the LP API
|
|
39
|
+
*/
|
|
40
|
+
private fetch;
|
|
41
|
+
/**
|
|
42
|
+
* Validate the API token by listing workspaces
|
|
43
|
+
*/
|
|
44
|
+
validateToken(): Promise<{
|
|
45
|
+
valid: boolean;
|
|
46
|
+
error?: string;
|
|
47
|
+
}>;
|
|
48
|
+
/**
|
|
49
|
+
* Get all workspaces accessible to the API token
|
|
50
|
+
*/
|
|
51
|
+
getWorkspaces(): Promise<{
|
|
52
|
+
workspaces?: LPWorkspace[];
|
|
53
|
+
error?: string;
|
|
54
|
+
}>;
|
|
55
|
+
/**
|
|
56
|
+
* Get all members in the workspace (with pagination)
|
|
57
|
+
*/
|
|
58
|
+
getWorkspaceMembers(): Promise<{
|
|
59
|
+
members?: LPMember[];
|
|
60
|
+
error?: string;
|
|
61
|
+
}>;
|
|
62
|
+
/**
|
|
63
|
+
* Get a single item by ID
|
|
64
|
+
*/
|
|
65
|
+
getItem(itemId: number): Promise<{
|
|
66
|
+
item?: LPItem;
|
|
67
|
+
error?: string;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Find all assignments under a task (with pagination)
|
|
71
|
+
*/
|
|
72
|
+
findAssignments(taskId: number): Promise<{
|
|
73
|
+
assignments?: LPItem[];
|
|
74
|
+
error?: string;
|
|
75
|
+
}>;
|
|
76
|
+
/**
|
|
77
|
+
* Get all assignments for a specific member
|
|
78
|
+
*
|
|
79
|
+
* This enables PWA apps to show a task picker populated from LP directly.
|
|
80
|
+
* Note: userId is not a supported filter field in the LP API, so we filter client-side.
|
|
81
|
+
*/
|
|
82
|
+
getMyAssignments(memberId: number): Promise<{
|
|
83
|
+
assignments?: LPItem[];
|
|
84
|
+
error?: string;
|
|
85
|
+
}>;
|
|
86
|
+
/**
|
|
87
|
+
* Get all cost codes in the workspace (with pagination)
|
|
88
|
+
*/
|
|
89
|
+
getCostCodes(): Promise<{
|
|
90
|
+
costCodes?: LPCostCode[];
|
|
91
|
+
error?: string;
|
|
92
|
+
}>;
|
|
93
|
+
/**
|
|
94
|
+
* Create a timesheet entry (log time)
|
|
95
|
+
*
|
|
96
|
+
* Uses the Logged Time Entries API to create time entries.
|
|
97
|
+
* POST /api/workspaces/{workspaceId}/logged-time-entries/v1
|
|
98
|
+
*/
|
|
99
|
+
createTimesheetEntry(entry: LPTimesheetEntry): Promise<LPSyncResult>;
|
|
100
|
+
/**
|
|
101
|
+
* Get timesheet entries for a specific date
|
|
102
|
+
*
|
|
103
|
+
* Uses the Logged Time Entries API to query existing entries.
|
|
104
|
+
* GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
|
|
105
|
+
*
|
|
106
|
+
* @see https://api-docs.liquidplanner.com/docs/task-status-1
|
|
107
|
+
*
|
|
108
|
+
* @param date - Date in YYYY-MM-DD format
|
|
109
|
+
* @param itemId - Optional item ID to filter by
|
|
110
|
+
*/
|
|
111
|
+
getTimesheetEntries(date: string, itemId?: number): Promise<{
|
|
112
|
+
entries?: LPTimesheetEntryWithId[];
|
|
113
|
+
error?: string;
|
|
114
|
+
}>;
|
|
115
|
+
/**
|
|
116
|
+
* Update an existing timesheet entry
|
|
117
|
+
*
|
|
118
|
+
* Uses the Logged Time Entries API to update an entry.
|
|
119
|
+
* PUT /api/workspaces/{workspaceId}/logged-time-entries/v1/{entryId}
|
|
120
|
+
*
|
|
121
|
+
* **API Quirk:** Field behaviors are inconsistent in this endpoint:
|
|
122
|
+
* - `loggedEntriesInMinutes`: Replaces existing value (absolute)
|
|
123
|
+
* - `note`: Appended to existing value by the API
|
|
124
|
+
*
|
|
125
|
+
* To avoid note duplication, pass empty string when not updating notes.
|
|
126
|
+
* To accumulate time, compute the new total client-side before calling.
|
|
127
|
+
*
|
|
128
|
+
* @see https://api-docs.liquidplanner.com/reference/updateloggedentry
|
|
129
|
+
*
|
|
130
|
+
* @param entryId - The ID of the entry to update
|
|
131
|
+
* @param existingEntry - The existing entry (needed because PUT requires all fields)
|
|
132
|
+
* @param updates - Fields to update (merged with existing)
|
|
133
|
+
*/
|
|
134
|
+
updateTimesheetEntry(entryId: number, existingEntry: LPTimesheetEntryWithId, updates: Partial<LPTimesheetEntry>): Promise<LPSyncResult>;
|
|
135
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner API Client
|
|
3
|
+
*
|
|
4
|
+
* Provides methods for interacting with the LiquidPlanner API.
|
|
5
|
+
* Uses Bearer Token authentication.
|
|
6
|
+
*
|
|
7
|
+
* @see https://api-docs.liquidplanner.com/
|
|
8
|
+
*/
|
|
9
|
+
import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
10
|
+
import { parseLPErrorResponse, getErrorMessage } from './errors.js';
|
|
11
|
+
import { LP_API_BASE, DEFAULT_ITEM_NAME } from './constants.js';
|
|
12
|
+
/** Transform raw API item to LPItem */
|
|
13
|
+
function transformItem(raw) {
|
|
14
|
+
return {
|
|
15
|
+
id: raw.id,
|
|
16
|
+
name: raw.name || DEFAULT_ITEM_NAME,
|
|
17
|
+
itemType: normalizeItemType(raw.itemType),
|
|
18
|
+
parentId: raw.parentId,
|
|
19
|
+
costCodeId: raw.costCodeId,
|
|
20
|
+
userId: raw.userId,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* LiquidPlanner API Client
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
|
|
29
|
+
*
|
|
30
|
+
* // Validate credentials
|
|
31
|
+
* const validation = await client.validateToken();
|
|
32
|
+
*
|
|
33
|
+
* // Get workspaces
|
|
34
|
+
* const workspaces = await client.getWorkspaces();
|
|
35
|
+
*
|
|
36
|
+
* // Log time
|
|
37
|
+
* await client.createTimesheetEntry({
|
|
38
|
+
* date: '2026-01-29',
|
|
39
|
+
* itemId: 12345,
|
|
40
|
+
* hours: 2.5,
|
|
41
|
+
* note: 'Working on feature'
|
|
42
|
+
* });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class LPClient {
|
|
46
|
+
constructor(config) {
|
|
47
|
+
this.apiToken = config.apiToken;
|
|
48
|
+
this.workspaceId = config.workspaceId;
|
|
49
|
+
this.baseUrl = config.baseUrl ?? LP_API_BASE;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Make an authenticated request to the LP API
|
|
53
|
+
*/
|
|
54
|
+
async fetch(url, options = {}) {
|
|
55
|
+
const { method = 'GET', body } = options;
|
|
56
|
+
return fetch(url, {
|
|
57
|
+
method,
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: buildAuthHeader(this.apiToken),
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
},
|
|
62
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Workspace & Validation
|
|
67
|
+
// ============================================================================
|
|
68
|
+
/**
|
|
69
|
+
* Validate the API token by listing workspaces
|
|
70
|
+
*/
|
|
71
|
+
async validateToken() {
|
|
72
|
+
const url = `${this.baseUrl}/workspaces/v1`;
|
|
73
|
+
try {
|
|
74
|
+
const response = await this.fetch(url);
|
|
75
|
+
if (response.ok) {
|
|
76
|
+
return { valid: true };
|
|
77
|
+
}
|
|
78
|
+
if (response.status === 401 || response.status === 403) {
|
|
79
|
+
return { valid: false, error: 'Invalid or expired API token' };
|
|
80
|
+
}
|
|
81
|
+
return { valid: false, error: `Unexpected response: HTTP ${response.status}` };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return { valid: false, error: getErrorMessage(error) || 'Connection failed' };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get all workspaces accessible to the API token
|
|
89
|
+
*/
|
|
90
|
+
async getWorkspaces() {
|
|
91
|
+
const url = `${this.baseUrl}/workspaces/v1`;
|
|
92
|
+
try {
|
|
93
|
+
const response = await this.fetch(url);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const errorText = await response.text();
|
|
96
|
+
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
97
|
+
return { error: message };
|
|
98
|
+
}
|
|
99
|
+
const result = await response.json();
|
|
100
|
+
const workspaces = (result.data || []).map(ws => ({
|
|
101
|
+
id: ws.id,
|
|
102
|
+
name: ws.name,
|
|
103
|
+
}));
|
|
104
|
+
return { workspaces };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return { error: getErrorMessage(error) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Members
|
|
112
|
+
// ============================================================================
|
|
113
|
+
/**
|
|
114
|
+
* Get all members in the workspace (with pagination)
|
|
115
|
+
*/
|
|
116
|
+
async getWorkspaceMembers() {
|
|
117
|
+
const baseUrl = `${this.baseUrl}/users/v1?${filterIs('workspaceId', this.workspaceId)}`;
|
|
118
|
+
const { results, error } = await paginatedFetch({
|
|
119
|
+
fetchFn: (url) => this.fetch(url),
|
|
120
|
+
baseUrl,
|
|
121
|
+
transform: (data) => data.map(m => ({
|
|
122
|
+
id: m.id,
|
|
123
|
+
username: m.username,
|
|
124
|
+
email: m.email,
|
|
125
|
+
firstName: m.firstName,
|
|
126
|
+
lastName: m.lastName,
|
|
127
|
+
userType: m.userType,
|
|
128
|
+
})),
|
|
129
|
+
});
|
|
130
|
+
return error ? { error } : { members: results };
|
|
131
|
+
}
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// Items
|
|
134
|
+
// ============================================================================
|
|
135
|
+
/**
|
|
136
|
+
* Get a single item by ID
|
|
137
|
+
*/
|
|
138
|
+
async getItem(itemId) {
|
|
139
|
+
const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
|
|
140
|
+
try {
|
|
141
|
+
const response = await this.fetch(url);
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const errorText = await response.text();
|
|
144
|
+
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
145
|
+
return { error: message };
|
|
146
|
+
}
|
|
147
|
+
const result = await response.json();
|
|
148
|
+
if (!result.data || result.data.length === 0) {
|
|
149
|
+
return { error: `Item ${itemId} not found` };
|
|
150
|
+
}
|
|
151
|
+
return { item: transformItem(result.data[0]) };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return { error: getErrorMessage(error) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Find all assignments under a task (with pagination)
|
|
159
|
+
*/
|
|
160
|
+
async findAssignments(taskId) {
|
|
161
|
+
// parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
|
|
162
|
+
const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`;
|
|
163
|
+
const { results, error } = await paginatedFetch({
|
|
164
|
+
fetchFn: (url) => this.fetch(url),
|
|
165
|
+
baseUrl,
|
|
166
|
+
transform: (data) => data.map(transformItem),
|
|
167
|
+
});
|
|
168
|
+
return error ? { error } : { assignments: results };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Get all assignments for a specific member
|
|
172
|
+
*
|
|
173
|
+
* This enables PWA apps to show a task picker populated from LP directly.
|
|
174
|
+
* Note: userId is not a supported filter field in the LP API, so we filter client-side.
|
|
175
|
+
*/
|
|
176
|
+
async getMyAssignments(memberId) {
|
|
177
|
+
const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
|
|
178
|
+
const { results, error } = await paginatedFetch({
|
|
179
|
+
fetchFn: (url) => this.fetch(url),
|
|
180
|
+
baseUrl,
|
|
181
|
+
filter: (data) => data.filter(item => item.userId === memberId),
|
|
182
|
+
transform: (data) => data.map(transformItem),
|
|
183
|
+
});
|
|
184
|
+
return error ? { error } : { assignments: results };
|
|
185
|
+
}
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// Cost Codes
|
|
188
|
+
// ============================================================================
|
|
189
|
+
/**
|
|
190
|
+
* Get all cost codes in the workspace (with pagination)
|
|
191
|
+
*/
|
|
192
|
+
async getCostCodes() {
|
|
193
|
+
const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
|
|
194
|
+
const { results, error } = await paginatedFetch({
|
|
195
|
+
fetchFn: (url) => this.fetch(url),
|
|
196
|
+
baseUrl,
|
|
197
|
+
transform: (data) => data.map(cc => ({
|
|
198
|
+
id: cc.id,
|
|
199
|
+
name: cc.name,
|
|
200
|
+
billable: cc.billable,
|
|
201
|
+
})),
|
|
202
|
+
});
|
|
203
|
+
return error ? { error } : { costCodes: results };
|
|
204
|
+
}
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Timesheet
|
|
207
|
+
// ============================================================================
|
|
208
|
+
/**
|
|
209
|
+
* Create a timesheet entry (log time)
|
|
210
|
+
*
|
|
211
|
+
* Uses the Logged Time Entries API to create time entries.
|
|
212
|
+
* POST /api/workspaces/{workspaceId}/logged-time-entries/v1
|
|
213
|
+
*/
|
|
214
|
+
async createTimesheetEntry(entry) {
|
|
215
|
+
const { date, itemId, hours, costCodeId, note } = entry;
|
|
216
|
+
const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1`;
|
|
217
|
+
// Build request body according to LP Logged Time Entries API
|
|
218
|
+
const body = {
|
|
219
|
+
date,
|
|
220
|
+
loggedEntriesInMinutes: hoursToMinutes(hours),
|
|
221
|
+
itemId,
|
|
222
|
+
};
|
|
223
|
+
// Add optional cost code
|
|
224
|
+
if (costCodeId) {
|
|
225
|
+
body.costCodeId = costCodeId;
|
|
226
|
+
}
|
|
227
|
+
// Add optional note
|
|
228
|
+
if (note) {
|
|
229
|
+
body.note = note;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const response = await this.fetch(url, { method: 'POST', body });
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const errorText = await response.text();
|
|
235
|
+
const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
|
|
236
|
+
return { success: false, error: message, isDuplicate };
|
|
237
|
+
}
|
|
238
|
+
const result = await response.json();
|
|
239
|
+
return { success: true, entryId: result.id };
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return { success: false, error: getErrorMessage(error) };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Get timesheet entries for a specific date
|
|
247
|
+
*
|
|
248
|
+
* Uses the Logged Time Entries API to query existing entries.
|
|
249
|
+
* GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
|
|
250
|
+
*
|
|
251
|
+
* @see https://api-docs.liquidplanner.com/docs/task-status-1
|
|
252
|
+
*
|
|
253
|
+
* @param date - Date in YYYY-MM-DD format
|
|
254
|
+
* @param itemId - Optional item ID to filter by
|
|
255
|
+
*/
|
|
256
|
+
async getTimesheetEntries(date, itemId) {
|
|
257
|
+
// Build query with date[in] filter (date field uses [in] operator with array)
|
|
258
|
+
let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', [date])}`;
|
|
259
|
+
// Optional filter by itemId
|
|
260
|
+
if (itemId) {
|
|
261
|
+
baseUrl += `&${filterIs('itemId', itemId)}`;
|
|
262
|
+
}
|
|
263
|
+
const { results, error } = await paginatedFetch({
|
|
264
|
+
fetchFn: (url) => this.fetch(url),
|
|
265
|
+
baseUrl,
|
|
266
|
+
transform: (data) => data.map(entry => ({
|
|
267
|
+
id: entry.id,
|
|
268
|
+
date: entry.date,
|
|
269
|
+
itemId: entry.itemId,
|
|
270
|
+
hours: entry.loggedEntriesInMinutes / 60,
|
|
271
|
+
costCodeId: entry.costCodeId,
|
|
272
|
+
note: entry.note,
|
|
273
|
+
userId: entry.userId,
|
|
274
|
+
})),
|
|
275
|
+
});
|
|
276
|
+
return error ? { error } : { entries: results };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Update an existing timesheet entry
|
|
280
|
+
*
|
|
281
|
+
* Uses the Logged Time Entries API to update an entry.
|
|
282
|
+
* PUT /api/workspaces/{workspaceId}/logged-time-entries/v1/{entryId}
|
|
283
|
+
*
|
|
284
|
+
* **API Quirk:** Field behaviors are inconsistent in this endpoint:
|
|
285
|
+
* - `loggedEntriesInMinutes`: Replaces existing value (absolute)
|
|
286
|
+
* - `note`: Appended to existing value by the API
|
|
287
|
+
*
|
|
288
|
+
* To avoid note duplication, pass empty string when not updating notes.
|
|
289
|
+
* To accumulate time, compute the new total client-side before calling.
|
|
290
|
+
*
|
|
291
|
+
* @see https://api-docs.liquidplanner.com/reference/updateloggedentry
|
|
292
|
+
*
|
|
293
|
+
* @param entryId - The ID of the entry to update
|
|
294
|
+
* @param existingEntry - The existing entry (needed because PUT requires all fields)
|
|
295
|
+
* @param updates - Fields to update (merged with existing)
|
|
296
|
+
*/
|
|
297
|
+
async updateTimesheetEntry(entryId, existingEntry, updates) {
|
|
298
|
+
const url = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1/${entryId}`;
|
|
299
|
+
// PUT requires all fields - merge updates with existing entry
|
|
300
|
+
// IMPORTANT: LP API appends the note field, so only send new notes
|
|
301
|
+
// If no new note, send empty string to avoid re-appending existing notes
|
|
302
|
+
const body = {
|
|
303
|
+
id: entryId,
|
|
304
|
+
date: updates.date ?? existingEntry.date,
|
|
305
|
+
itemId: updates.itemId ?? existingEntry.itemId,
|
|
306
|
+
loggedEntriesInMinutes: hoursToMinutes(updates.hours ?? existingEntry.hours),
|
|
307
|
+
costCodeId: updates.costCodeId ?? existingEntry.costCodeId,
|
|
308
|
+
note: updates.note ?? '',
|
|
309
|
+
userId: existingEntry.userId,
|
|
310
|
+
};
|
|
311
|
+
try {
|
|
312
|
+
const response = await this.fetch(url, { method: 'PUT', body });
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
const errorText = await response.text();
|
|
315
|
+
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
316
|
+
return { success: false, error: message };
|
|
317
|
+
}
|
|
318
|
+
return { success: true, entryId };
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
return { success: false, error: getErrorMessage(error) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Constants
|
|
3
|
+
*
|
|
4
|
+
* Centralized constants used across the library.
|
|
5
|
+
*/
|
|
6
|
+
/** Default LP API base URL */
|
|
7
|
+
export declare const LP_API_BASE = "https://next.liquidplanner.com/api";
|
|
8
|
+
/** Default name for items when not available */
|
|
9
|
+
export declare const DEFAULT_ITEM_NAME = "-";
|
|
10
|
+
/** Default name for assignments when not available */
|
|
11
|
+
export declare const DEFAULT_ASSIGNMENT_NAME = "-";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Constants
|
|
3
|
+
*
|
|
4
|
+
* Centralized constants used across the library.
|
|
5
|
+
*/
|
|
6
|
+
/** Default LP API base URL */
|
|
7
|
+
export const LP_API_BASE = 'https://next.liquidplanner.com/api';
|
|
8
|
+
/** Default name for items when not available */
|
|
9
|
+
export const DEFAULT_ITEM_NAME = '-';
|
|
10
|
+
/** Default name for assignments when not available */
|
|
11
|
+
export const DEFAULT_ASSIGNMENT_NAME = '-';
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Error Handling
|
|
3
|
+
*
|
|
4
|
+
* LP returns errors in various formats:
|
|
5
|
+
* - {"errors":[{"title":"...","detail":"...","code":"..."}]}
|
|
6
|
+
* - {"message":"..."} or {"error":"..."}
|
|
7
|
+
* - Plain text
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Parsed LP error response
|
|
11
|
+
*/
|
|
12
|
+
export interface LPParsedError {
|
|
13
|
+
/** Human-readable error message */
|
|
14
|
+
message: string;
|
|
15
|
+
/** Whether this error indicates a duplicate entry */
|
|
16
|
+
isDuplicate?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse LP API error response text into a human-readable message.
|
|
20
|
+
*
|
|
21
|
+
* @param errorText - Raw error response text
|
|
22
|
+
* @param statusCode - HTTP status code
|
|
23
|
+
* @returns Parsed error with message and optional duplicate flag
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseLPErrorResponse(errorText: string, statusCode: number): LPParsedError;
|
|
26
|
+
/**
|
|
27
|
+
* Custom error class for LP API errors
|
|
28
|
+
*/
|
|
29
|
+
export declare class LPError extends Error {
|
|
30
|
+
/** HTTP status code */
|
|
31
|
+
statusCode: number;
|
|
32
|
+
/** Whether this is a duplicate entry error */
|
|
33
|
+
isDuplicate: boolean;
|
|
34
|
+
/** Raw error response */
|
|
35
|
+
rawResponse?: string;
|
|
36
|
+
constructor(message: string, statusCode: number, options?: {
|
|
37
|
+
isDuplicate?: boolean;
|
|
38
|
+
rawResponse?: string;
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* Create an LPError from an API response
|
|
42
|
+
*/
|
|
43
|
+
static fromResponse(statusCode: number, responseText: string): LPError;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get a safe error message from any error type
|
|
47
|
+
*/
|
|
48
|
+
export declare function getErrorMessage(error: unknown): string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Error Handling
|
|
3
|
+
*
|
|
4
|
+
* LP returns errors in various formats:
|
|
5
|
+
* - {"errors":[{"title":"...","detail":"...","code":"..."}]}
|
|
6
|
+
* - {"message":"..."} or {"error":"..."}
|
|
7
|
+
* - Plain text
|
|
8
|
+
*/
|
|
9
|
+
/** Error code for duplicate entry errors */
|
|
10
|
+
const LP_ERROR_CODE_DUPLICATE = 'duplicate_value';
|
|
11
|
+
/** Default error message when none is available */
|
|
12
|
+
const DEFAULT_ERROR_MESSAGE = 'Unknown error';
|
|
13
|
+
/**
|
|
14
|
+
* Parse LP API error response text into a human-readable message.
|
|
15
|
+
*
|
|
16
|
+
* @param errorText - Raw error response text
|
|
17
|
+
* @param statusCode - HTTP status code
|
|
18
|
+
* @returns Parsed error with message and optional duplicate flag
|
|
19
|
+
*/
|
|
20
|
+
export function parseLPErrorResponse(errorText, statusCode) {
|
|
21
|
+
try {
|
|
22
|
+
const errorJson = JSON.parse(errorText);
|
|
23
|
+
// LP returns errors array with structured error objects
|
|
24
|
+
if (errorJson.errors && Array.isArray(errorJson.errors)) {
|
|
25
|
+
const isDuplicate = errorJson.errors.some((err) => err.code === LP_ERROR_CODE_DUPLICATE);
|
|
26
|
+
const firstError = errorJson.errors[0];
|
|
27
|
+
const message = firstError?.detail || firstError?.title || `HTTP ${statusCode}`;
|
|
28
|
+
return { message, isDuplicate };
|
|
29
|
+
}
|
|
30
|
+
// Fallback to message/error fields
|
|
31
|
+
return {
|
|
32
|
+
message: errorJson.message || errorJson.error || `HTTP ${statusCode}`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Not JSON, return as-is or fallback
|
|
37
|
+
return { message: errorText || `HTTP ${statusCode}` };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Custom error class for LP API errors
|
|
42
|
+
*/
|
|
43
|
+
export class LPError extends Error {
|
|
44
|
+
constructor(message, statusCode, options) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'LPError';
|
|
47
|
+
this.statusCode = statusCode;
|
|
48
|
+
this.isDuplicate = options?.isDuplicate ?? false;
|
|
49
|
+
this.rawResponse = options?.rawResponse;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create an LPError from an API response
|
|
53
|
+
*/
|
|
54
|
+
static fromResponse(statusCode, responseText) {
|
|
55
|
+
const parsed = parseLPErrorResponse(responseText, statusCode);
|
|
56
|
+
return new LPError(parsed.message, statusCode, {
|
|
57
|
+
isDuplicate: parsed.isDuplicate,
|
|
58
|
+
rawResponse: responseText,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @markwharton/liquidplanner
|
|
3
|
+
*
|
|
4
|
+
* LiquidPlanner API client for timesheet integration.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { LPClient, resolveTaskToAssignment } from '@markwharton/liquidplanner';
|
|
9
|
+
*
|
|
10
|
+
* const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
|
|
11
|
+
*
|
|
12
|
+
* // Validate credentials
|
|
13
|
+
* await client.validateToken();
|
|
14
|
+
*
|
|
15
|
+
* // Get workspaces
|
|
16
|
+
* const { workspaces } = await client.getWorkspaces();
|
|
17
|
+
*
|
|
18
|
+
* // Resolve task to assignment
|
|
19
|
+
* const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
20
|
+
*
|
|
21
|
+
* // Log time
|
|
22
|
+
* await client.createTimesheetEntry({
|
|
23
|
+
* date: '2026-01-29',
|
|
24
|
+
* itemId: resolution.assignmentId,
|
|
25
|
+
* hours: 2.5
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export { LPClient } from './client.js';
|
|
30
|
+
export { resolveTaskToAssignment } from './workflows.js';
|
|
31
|
+
export type { LPConfig, LPItemType, LPItem, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, } from './types.js';
|
|
32
|
+
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
33
|
+
export type { PaginateOptions } from './utils.js';
|
|
34
|
+
export { LP_API_BASE, DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME, } from './constants.js';
|
|
35
|
+
export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
|
|
36
|
+
export type { LPParsedError } from './errors.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @markwharton/liquidplanner
|
|
3
|
+
*
|
|
4
|
+
* LiquidPlanner API client for timesheet integration.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { LPClient, resolveTaskToAssignment } from '@markwharton/liquidplanner';
|
|
9
|
+
*
|
|
10
|
+
* const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
|
|
11
|
+
*
|
|
12
|
+
* // Validate credentials
|
|
13
|
+
* await client.validateToken();
|
|
14
|
+
*
|
|
15
|
+
* // Get workspaces
|
|
16
|
+
* const { workspaces } = await client.getWorkspaces();
|
|
17
|
+
*
|
|
18
|
+
* // Resolve task to assignment
|
|
19
|
+
* const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
20
|
+
*
|
|
21
|
+
* // Log time
|
|
22
|
+
* await client.createTimesheetEntry({
|
|
23
|
+
* date: '2026-01-29',
|
|
24
|
+
* itemId: resolution.assignmentId,
|
|
25
|
+
* hours: 2.5
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
// Main client
|
|
30
|
+
export { LPClient } from './client.js';
|
|
31
|
+
// Workflows
|
|
32
|
+
export { resolveTaskToAssignment } from './workflows.js';
|
|
33
|
+
// Utilities
|
|
34
|
+
export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
|
|
35
|
+
// Constants
|
|
36
|
+
export { LP_API_BASE, DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME, } from './constants.js';
|
|
37
|
+
// Errors
|
|
38
|
+
export { LPError, parseLPErrorResponse, getErrorMessage } from './errors.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* These types define the data structures used when interacting with
|
|
5
|
+
* the LiquidPlanner API.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* LiquidPlanner item types in the hierarchy
|
|
9
|
+
*/
|
|
10
|
+
export type LPItemType = 'Task' | 'Assignment' | 'Folder' | 'Milestone' | 'Event';
|
|
11
|
+
/**
|
|
12
|
+
* An item from LiquidPlanner (Task, Assignment, Folder, etc.)
|
|
13
|
+
*/
|
|
14
|
+
export interface LPItem {
|
|
15
|
+
/** Unique identifier */
|
|
16
|
+
id: number;
|
|
17
|
+
/** Display name */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Type of item in LP hierarchy */
|
|
20
|
+
itemType: LPItemType;
|
|
21
|
+
/** Parent item ID (e.g., Assignment's parent is a Task) */
|
|
22
|
+
parentId?: number;
|
|
23
|
+
/** Cost code ID if assigned */
|
|
24
|
+
costCodeId?: number;
|
|
25
|
+
/** User ID this assignment is for (if Assignment type) */
|
|
26
|
+
userId?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A cost code from LiquidPlanner
|
|
30
|
+
*/
|
|
31
|
+
export interface LPCostCode {
|
|
32
|
+
/** Unique identifier */
|
|
33
|
+
id: number;
|
|
34
|
+
/** Display name */
|
|
35
|
+
name: string;
|
|
36
|
+
/** Whether this cost code is billable */
|
|
37
|
+
billable: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A workspace from LiquidPlanner
|
|
41
|
+
*/
|
|
42
|
+
export interface LPWorkspace {
|
|
43
|
+
/** Unique identifier */
|
|
44
|
+
id: number;
|
|
45
|
+
/** Workspace name */
|
|
46
|
+
name: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A workspace member from LiquidPlanner
|
|
50
|
+
*/
|
|
51
|
+
export interface LPMember {
|
|
52
|
+
/** Unique identifier */
|
|
53
|
+
id: number;
|
|
54
|
+
/** Username (login name) */
|
|
55
|
+
username: string;
|
|
56
|
+
/** Email address */
|
|
57
|
+
email: string;
|
|
58
|
+
/** First name */
|
|
59
|
+
firstName: string;
|
|
60
|
+
/** Last name */
|
|
61
|
+
lastName: string;
|
|
62
|
+
/** User type (member, resource, or placeholder) */
|
|
63
|
+
userType: 'member' | 'resource' | 'placeholder';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Result of resolving an LP Item ID to the correct Assignment ID for logging time
|
|
67
|
+
*/
|
|
68
|
+
export interface LPTaskResolution {
|
|
69
|
+
/** The original item that was looked up */
|
|
70
|
+
inputItem: LPItem;
|
|
71
|
+
/** The Assignment ID to use for logging time (may be same as input if input was an Assignment) */
|
|
72
|
+
assignmentId: number;
|
|
73
|
+
/** Name of the assignment */
|
|
74
|
+
assignmentName?: string;
|
|
75
|
+
/** User ID the assignment belongs to (LP member ID) */
|
|
76
|
+
assignmentUserId?: number;
|
|
77
|
+
/** If Task had multiple Assignments, list them for user selection */
|
|
78
|
+
multipleAssignments?: LPItem[];
|
|
79
|
+
/** Error message if resolution failed */
|
|
80
|
+
error?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* LiquidPlanner configuration for API access
|
|
84
|
+
*/
|
|
85
|
+
export interface LPConfig {
|
|
86
|
+
/** Workspace ID to operate on */
|
|
87
|
+
workspaceId: number;
|
|
88
|
+
/** API token for authentication */
|
|
89
|
+
apiToken: string;
|
|
90
|
+
/** Base URL for LP API (defaults to https://next.liquidplanner.com/api) */
|
|
91
|
+
baseUrl?: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Result of a timesheet sync operation
|
|
95
|
+
*/
|
|
96
|
+
export interface LPSyncResult {
|
|
97
|
+
/** Whether the operation succeeded */
|
|
98
|
+
success: boolean;
|
|
99
|
+
/** ID of the created entry (if successful) */
|
|
100
|
+
entryId?: number;
|
|
101
|
+
/** Error message (if failed) */
|
|
102
|
+
error?: string;
|
|
103
|
+
/** Whether the error was due to a duplicate entry */
|
|
104
|
+
isDuplicate?: boolean;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Input for a timesheet entry to sync
|
|
108
|
+
*/
|
|
109
|
+
export interface LPTimesheetEntry {
|
|
110
|
+
/** Date in YYYY-MM-DD format */
|
|
111
|
+
date: string;
|
|
112
|
+
/** Item ID (should be an Assignment ID) */
|
|
113
|
+
itemId: number;
|
|
114
|
+
/** Hours worked (decimal) */
|
|
115
|
+
hours: number;
|
|
116
|
+
/** Optional cost code ID */
|
|
117
|
+
costCodeId?: number;
|
|
118
|
+
/** Optional note/description */
|
|
119
|
+
note?: string;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A timesheet entry returned from LP API queries (includes ID)
|
|
123
|
+
*/
|
|
124
|
+
export interface LPTimesheetEntryWithId extends LPTimesheetEntry {
|
|
125
|
+
/** Unique identifier for the entry */
|
|
126
|
+
id: number;
|
|
127
|
+
/** User ID who logged the time */
|
|
128
|
+
userId?: number;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Generic result wrapper for LP operations
|
|
132
|
+
*/
|
|
133
|
+
export interface LPResult<T> {
|
|
134
|
+
/** The data if successful */
|
|
135
|
+
data?: T;
|
|
136
|
+
/** Error message if failed */
|
|
137
|
+
error?: string;
|
|
138
|
+
}
|
package/dist/types.js
ADDED
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Utility Functions
|
|
3
|
+
*/
|
|
4
|
+
import type { LPItemType } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Build a URL-encoded filter for LP API: field[is]="value"
|
|
7
|
+
*/
|
|
8
|
+
export declare function filterIs(field: string, value: string | number): string;
|
|
9
|
+
/**
|
|
10
|
+
* Build a URL-encoded filter for LP API: field[in]=["value1","value2"]
|
|
11
|
+
*/
|
|
12
|
+
export declare function filterIn(field: string, values: (string | number)[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* Options for paginated fetch
|
|
15
|
+
*/
|
|
16
|
+
export interface PaginateOptions<TRaw, TResult> {
|
|
17
|
+
/** Authenticated fetch function */
|
|
18
|
+
fetchFn: (url: string) => Promise<Response>;
|
|
19
|
+
/** Base URL (without continuation token) */
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
/** Transform raw API data to result type */
|
|
22
|
+
transform: (data: TRaw[]) => TResult[];
|
|
23
|
+
/** Optional filter to apply to each page */
|
|
24
|
+
filter?: (data: TRaw[]) => TRaw[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generic pagination helper for LP API endpoints
|
|
28
|
+
*
|
|
29
|
+
* Handles the continuation token pattern used by LP API.
|
|
30
|
+
*/
|
|
31
|
+
export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<{
|
|
32
|
+
results?: TResult[];
|
|
33
|
+
error?: string;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Convert decimal hours to minutes
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* hoursToMinutes(6.5) // 390
|
|
40
|
+
* hoursToMinutes(2.25) // 135
|
|
41
|
+
* hoursToMinutes(0.5) // 30
|
|
42
|
+
*
|
|
43
|
+
* @throws Error if hours is not a valid non-negative number
|
|
44
|
+
*/
|
|
45
|
+
export declare function hoursToMinutes(hours: number): number;
|
|
46
|
+
/**
|
|
47
|
+
* Normalize LP API itemType values to internal format
|
|
48
|
+
*
|
|
49
|
+
* LP API returns lowercase plural (e.g., "tasks", "assignments")
|
|
50
|
+
* but our code uses PascalCase singular (e.g., "Task", "Assignment")
|
|
51
|
+
*/
|
|
52
|
+
export declare function normalizeItemType(apiItemType: string): LPItemType;
|
|
53
|
+
/**
|
|
54
|
+
* Build the Authorization header for LP API requests
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildAuthHeader(apiToken: string): string;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Utility Functions
|
|
3
|
+
*/
|
|
4
|
+
import { parseLPErrorResponse, getErrorMessage } from './errors.js';
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// LP API Filter Builders
|
|
7
|
+
// ============================================================================
|
|
8
|
+
/**
|
|
9
|
+
* Build a URL-encoded filter for LP API: field[is]="value"
|
|
10
|
+
*/
|
|
11
|
+
export function filterIs(field, value) {
|
|
12
|
+
return `${field}%5Bis%5D=%22${value}%22`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build a URL-encoded filter for LP API: field[in]=["value1","value2"]
|
|
16
|
+
*/
|
|
17
|
+
export function filterIn(field, values) {
|
|
18
|
+
const encoded = values.map(v => `%22${v}%22`).join(',');
|
|
19
|
+
return `${field}%5Bin%5D=%5B${encoded}%5D`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Generic pagination helper for LP API endpoints
|
|
23
|
+
*
|
|
24
|
+
* Handles the continuation token pattern used by LP API.
|
|
25
|
+
*/
|
|
26
|
+
export async function paginatedFetch(options) {
|
|
27
|
+
const { fetchFn, baseUrl, transform, filter } = options;
|
|
28
|
+
const hasQueryParams = baseUrl.includes('?');
|
|
29
|
+
try {
|
|
30
|
+
const allResults = [];
|
|
31
|
+
let continuationToken;
|
|
32
|
+
do {
|
|
33
|
+
const url = continuationToken
|
|
34
|
+
? `${baseUrl}${hasQueryParams ? '&' : '?'}continuationToken=${continuationToken}`
|
|
35
|
+
: baseUrl;
|
|
36
|
+
const response = await fetchFn(url);
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const errorText = await response.text();
|
|
39
|
+
const { message } = parseLPErrorResponse(errorText, response.status);
|
|
40
|
+
return { error: message };
|
|
41
|
+
}
|
|
42
|
+
const result = await response.json();
|
|
43
|
+
const rawData = result.data || [];
|
|
44
|
+
const filteredData = filter ? filter(rawData) : rawData;
|
|
45
|
+
const pageResults = transform(filteredData);
|
|
46
|
+
allResults.push(...pageResults);
|
|
47
|
+
continuationToken = result.continuationToken;
|
|
48
|
+
} while (continuationToken);
|
|
49
|
+
return { results: allResults };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return { error: getErrorMessage(error) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Convert decimal hours to minutes
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* hoursToMinutes(6.5) // 390
|
|
60
|
+
* hoursToMinutes(2.25) // 135
|
|
61
|
+
* hoursToMinutes(0.5) // 30
|
|
62
|
+
*
|
|
63
|
+
* @throws Error if hours is not a valid non-negative number
|
|
64
|
+
*/
|
|
65
|
+
export function hoursToMinutes(hours) {
|
|
66
|
+
if (!Number.isFinite(hours) || hours < 0) {
|
|
67
|
+
throw new Error(`Invalid hours value: ${hours}`);
|
|
68
|
+
}
|
|
69
|
+
return Math.round(hours * 60);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Normalize LP API itemType values to internal format
|
|
73
|
+
*
|
|
74
|
+
* LP API returns lowercase plural (e.g., "tasks", "assignments")
|
|
75
|
+
* but our code uses PascalCase singular (e.g., "Task", "Assignment")
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeItemType(apiItemType) {
|
|
78
|
+
const mapping = {
|
|
79
|
+
// LP API lowercase plural format
|
|
80
|
+
'tasks': 'Task',
|
|
81
|
+
'assignments': 'Assignment',
|
|
82
|
+
'folders': 'Folder',
|
|
83
|
+
'milestones': 'Milestone',
|
|
84
|
+
'events': 'Event',
|
|
85
|
+
// Already-normalized values (for safety)
|
|
86
|
+
'Task': 'Task',
|
|
87
|
+
'Assignment': 'Assignment',
|
|
88
|
+
'Folder': 'Folder',
|
|
89
|
+
'Milestone': 'Milestone',
|
|
90
|
+
'Event': 'Event',
|
|
91
|
+
};
|
|
92
|
+
return mapping[apiItemType] || apiItemType;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build the Authorization header for LP API requests
|
|
96
|
+
*/
|
|
97
|
+
export function buildAuthHeader(apiToken) {
|
|
98
|
+
return `Bearer ${apiToken}`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Workflows
|
|
3
|
+
*
|
|
4
|
+
* Higher-level functions that combine multiple API calls to accomplish
|
|
5
|
+
* common tasks, like resolving a Task ID to the correct Assignment ID.
|
|
6
|
+
*/
|
|
7
|
+
import type { LPClient } from './client.js';
|
|
8
|
+
import type { LPTaskResolution } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Resolve an item ID to the correct Assignment ID for logging time
|
|
11
|
+
*
|
|
12
|
+
* LP hierarchy: Folder -> Task -> Assignment
|
|
13
|
+
* - User typically enters Task ID (what they see in LP UI)
|
|
14
|
+
* - API needs Assignment ID to log time
|
|
15
|
+
*
|
|
16
|
+
* Resolution logic:
|
|
17
|
+
* 1. If item is an Assignment -> use it directly
|
|
18
|
+
* 2. If item is a Task -> find Assignments underneath
|
|
19
|
+
* - If lpMemberId is provided, filter to assignment matching that user
|
|
20
|
+
* 3. If item is a Folder -> error (can't log time to folders)
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
25
|
+
*
|
|
26
|
+
* if (resolution.error) {
|
|
27
|
+
* console.error(resolution.error);
|
|
28
|
+
* if (resolution.multipleAssignments) {
|
|
29
|
+
* // Show picker UI for user to select
|
|
30
|
+
* }
|
|
31
|
+
* } else {
|
|
32
|
+
* await client.createTimesheetEntry({
|
|
33
|
+
* date: '2026-01-29',
|
|
34
|
+
* itemId: resolution.assignmentId,
|
|
35
|
+
* hours: 2.5
|
|
36
|
+
* });
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveTaskToAssignment(client: LPClient, itemId: number, lpMemberId?: number): Promise<LPTaskResolution>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiquidPlanner Workflows
|
|
3
|
+
*
|
|
4
|
+
* Higher-level functions that combine multiple API calls to accomplish
|
|
5
|
+
* common tasks, like resolving a Task ID to the correct Assignment ID.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_ITEM_NAME, DEFAULT_ASSIGNMENT_NAME } from './constants.js';
|
|
8
|
+
/**
|
|
9
|
+
* Resolve an item ID to the correct Assignment ID for logging time
|
|
10
|
+
*
|
|
11
|
+
* LP hierarchy: Folder -> Task -> Assignment
|
|
12
|
+
* - User typically enters Task ID (what they see in LP UI)
|
|
13
|
+
* - API needs Assignment ID to log time
|
|
14
|
+
*
|
|
15
|
+
* Resolution logic:
|
|
16
|
+
* 1. If item is an Assignment -> use it directly
|
|
17
|
+
* 2. If item is a Task -> find Assignments underneath
|
|
18
|
+
* - If lpMemberId is provided, filter to assignment matching that user
|
|
19
|
+
* 3. If item is a Folder -> error (can't log time to folders)
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* const resolution = await resolveTaskToAssignment(client, taskId, memberId);
|
|
24
|
+
*
|
|
25
|
+
* if (resolution.error) {
|
|
26
|
+
* console.error(resolution.error);
|
|
27
|
+
* if (resolution.multipleAssignments) {
|
|
28
|
+
* // Show picker UI for user to select
|
|
29
|
+
* }
|
|
30
|
+
* } else {
|
|
31
|
+
* await client.createTimesheetEntry({
|
|
32
|
+
* date: '2026-01-29',
|
|
33
|
+
* itemId: resolution.assignmentId,
|
|
34
|
+
* hours: 2.5
|
|
35
|
+
* });
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
|
|
40
|
+
// Step 1: Fetch the item
|
|
41
|
+
const { item, error: fetchError } = await client.getItem(itemId);
|
|
42
|
+
if (fetchError || !item) {
|
|
43
|
+
return {
|
|
44
|
+
inputItem: { id: itemId, name: DEFAULT_ITEM_NAME, itemType: 'Task' },
|
|
45
|
+
assignmentId: 0,
|
|
46
|
+
error: fetchError || 'Item not found',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Step 2: Check item type and resolve accordingly
|
|
50
|
+
switch (item.itemType) {
|
|
51
|
+
case 'Assignment':
|
|
52
|
+
// Already an assignment, use it directly
|
|
53
|
+
return {
|
|
54
|
+
inputItem: item,
|
|
55
|
+
assignmentId: item.id,
|
|
56
|
+
assignmentName: item.name || DEFAULT_ASSIGNMENT_NAME,
|
|
57
|
+
assignmentUserId: item.userId,
|
|
58
|
+
};
|
|
59
|
+
case 'Task': {
|
|
60
|
+
// Find assignments under this task
|
|
61
|
+
const { assignments, error: assignmentError } = await client.findAssignments(item.id);
|
|
62
|
+
if (assignmentError) {
|
|
63
|
+
return {
|
|
64
|
+
inputItem: item,
|
|
65
|
+
assignmentId: 0,
|
|
66
|
+
error: `Failed to find assignments: ${assignmentError}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (!assignments || assignments.length === 0) {
|
|
70
|
+
return {
|
|
71
|
+
inputItem: item,
|
|
72
|
+
assignmentId: 0,
|
|
73
|
+
error: 'No assignments found for this task. Time can only be logged to assignments.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (assignments.length === 1) {
|
|
77
|
+
// Single assignment - use it
|
|
78
|
+
return {
|
|
79
|
+
inputItem: item,
|
|
80
|
+
assignmentId: assignments[0].id,
|
|
81
|
+
assignmentName: assignments[0].name || DEFAULT_ASSIGNMENT_NAME,
|
|
82
|
+
assignmentUserId: assignments[0].userId,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Multiple assignments - try to filter by lpMemberId if provided
|
|
86
|
+
if (lpMemberId) {
|
|
87
|
+
const myAssignments = assignments.filter(a => a.userId === lpMemberId);
|
|
88
|
+
if (myAssignments.length === 1) {
|
|
89
|
+
// Single match - auto-select
|
|
90
|
+
return {
|
|
91
|
+
inputItem: item,
|
|
92
|
+
assignmentId: myAssignments[0].id,
|
|
93
|
+
assignmentName: myAssignments[0].name || DEFAULT_ASSIGNMENT_NAME,
|
|
94
|
+
assignmentUserId: myAssignments[0].userId,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (myAssignments.length > 1) {
|
|
98
|
+
// Multiple matches for this user - let them choose
|
|
99
|
+
return {
|
|
100
|
+
inputItem: item,
|
|
101
|
+
assignmentId: 0,
|
|
102
|
+
error: 'Multiple assignments found for your user. Please select one.',
|
|
103
|
+
multipleAssignments: myAssignments,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// lpMemberId is set but no assignments match - don't show others' assignments
|
|
107
|
+
return {
|
|
108
|
+
inputItem: item,
|
|
109
|
+
assignmentId: 0,
|
|
110
|
+
error: 'No assignments found for your configured LP Member ID.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// No lpMemberId configured - show all for selection
|
|
114
|
+
return {
|
|
115
|
+
inputItem: item,
|
|
116
|
+
assignmentId: 0,
|
|
117
|
+
error: 'Multiple assignments found. Configure LP Member ID in settings to auto-select.',
|
|
118
|
+
multipleAssignments: assignments,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
case 'Folder':
|
|
122
|
+
case 'Milestone':
|
|
123
|
+
case 'Event':
|
|
124
|
+
return {
|
|
125
|
+
inputItem: item,
|
|
126
|
+
assignmentId: 0,
|
|
127
|
+
error: `Cannot log time to a ${item.itemType}. Enter a Task or Assignment ID.`,
|
|
128
|
+
};
|
|
129
|
+
default:
|
|
130
|
+
return {
|
|
131
|
+
inputItem: item,
|
|
132
|
+
assignmentId: 0,
|
|
133
|
+
error: `Unsupported item type: ${item.itemType}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@markwharton/liquidplanner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "LiquidPlanner API client for timesheet integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"clean": "rm -rf dist"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^20.10.0",
|
|
20
|
+
"typescript": "^5.3.0"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/MarkWharton/api-packages.git",
|
|
28
|
+
"directory": "packages/liquidplanner"
|
|
29
|
+
},
|
|
30
|
+
"author": "Mark Wharton",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
}
|
|
35
|
+
}
|