viviscape-mcp 2.0.2 → 2.2.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 +43 -1
- package/dist/api-client.d.ts +151 -1
- package/dist/api-client.js +515 -71
- package/dist/auth/permissions.js +2 -0
- package/dist/enums.d.ts +48 -0
- package/dist/enums.js +67 -0
- package/dist/index.js +463 -34
- package/dist/projection.d.ts +47 -0
- package/dist/projection.js +132 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { API_PREFIX, baseUrl } from './config.js';
|
|
1
|
+
import { API_PREFIX, APP_NAME, baseUrl } from './config.js';
|
|
2
|
+
import { GROUP_TYPES } from './enums.js';
|
|
2
3
|
import { featureForPath, requireFeature } from './auth/permissions.js';
|
|
3
4
|
/** Raised when the stored session is rejected or bounced to the login page. */
|
|
4
5
|
export class SessionExpiredError extends Error {
|
|
@@ -13,6 +14,25 @@ export class ApiError extends Error {
|
|
|
13
14
|
this.status = status;
|
|
14
15
|
}
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* GroupMilestone payload. The API wants the whole record, so fill the fields it
|
|
19
|
+
* computes with neutral values and let an update merge over the existing row.
|
|
20
|
+
*/
|
|
21
|
+
function buildMilestonePayload(data, ctx, existing) {
|
|
22
|
+
const est = data.estimated_hours ? parseShorthandDuration(String(data.estimated_hours)).duration : '';
|
|
23
|
+
return {
|
|
24
|
+
milestone_id: Number(data.milestone_id ?? existing?.milestone_id ?? 0),
|
|
25
|
+
group_id: Number(data.project_id ?? existing?.group_id ?? 0),
|
|
26
|
+
user_id: ctx.userId,
|
|
27
|
+
milestone: (data.title ?? existing?.milestone ?? ''),
|
|
28
|
+
sequence: Number(data.sequence ?? existing?.sequence ?? 0),
|
|
29
|
+
start_date: isoDate(data.start_date, String(existing?.start_date ?? nowIso())),
|
|
30
|
+
due_date: isoDate(data.due_date, String(existing?.due_date ?? nowIso())),
|
|
31
|
+
estimated_time: est || existing?.estimated_time || '',
|
|
32
|
+
complete: Boolean(data.complete ?? existing?.complete ?? false),
|
|
33
|
+
progress: Number(data.progress ?? existing?.progress ?? 0),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
16
36
|
/**
|
|
17
37
|
* Parse shorthand duration (e.g., "3h", "1h30m", "45m") to HH:MM:SS TimeSpan format.
|
|
18
38
|
*/
|
|
@@ -33,74 +53,47 @@ function parseShorthandDuration(input) {
|
|
|
33
53
|
/**
|
|
34
54
|
* Build a full Group_Task payload from simplified MCP tool params.
|
|
35
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* Group_Task create payload.
|
|
58
|
+
*
|
|
59
|
+
* Modelled on what the ViviScape Work UI actually posts to tasks/add
|
|
60
|
+
* (ClockWyzWeb/Scripts/app/tasks.js, \$scope.nt): fourteen fields, ids as
|
|
61
|
+
* strings, estimated_time as shorthand text, and assignment expressed as
|
|
62
|
+
* staff_assignees [{user_id, attention}].
|
|
63
|
+
*
|
|
64
|
+
* The previous version sent a 37-field row with estimated_ticks and a
|
|
65
|
+
* hand-computed priority_rank, which the API rejected outright ("Invalid Server
|
|
66
|
+
* Request"). priority_rank is server-computed -- the UI never sends it, and the
|
|
67
|
+
* live values are not ordinal (low=3, moderate=27, important=86).
|
|
68
|
+
*/
|
|
36
69
|
function buildTaskPayload(data, ctx) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
leader: true,
|
|
46
|
-
attention: false,
|
|
47
|
-
promoting: false,
|
|
48
|
-
pinned: false,
|
|
49
|
-
notify: true,
|
|
50
|
-
active: false,
|
|
51
|
-
activitys: [],
|
|
52
|
-
duration: '00:00:00',
|
|
53
|
-
duration_friendly: '-',
|
|
54
|
-
});
|
|
55
|
-
// Convert estimated_hours shorthand to ticks (1 tick = 100ns, 1h = 36_000_000_000 ticks)
|
|
56
|
-
let estimatedTicks = 0;
|
|
57
|
-
if (data.estimated_hours) {
|
|
58
|
-
const parsed = parseShorthandDuration(String(data.estimated_hours));
|
|
59
|
-
const [h, m] = parsed.duration.split(':').map(Number);
|
|
60
|
-
estimatedTicks = ((h * 60 + m) * 60) * 10_000_000;
|
|
61
|
-
}
|
|
62
|
-
const priorityMap = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
63
|
-
const priorityStr = String(data.priority || 'medium');
|
|
64
|
-
const priorityRank = priorityMap[priorityStr] ?? 2;
|
|
65
|
-
return {
|
|
66
|
-
task_id: 0,
|
|
67
|
-
group_id: data.project_id ?? 0,
|
|
68
|
-
company_id: data.company_id ?? 0,
|
|
69
|
-
service_id: data.service_id ?? 0,
|
|
70
|
+
const start = isoDate(data.start_date, nowIso());
|
|
71
|
+
const payload = {
|
|
72
|
+
status: data.status || 'new',
|
|
73
|
+
account_id: ctx.accountId,
|
|
74
|
+
service_id: String(data.service_id ?? 0),
|
|
75
|
+
estimated_time: data.estimated_hours ? String(data.estimated_hours) : '8h',
|
|
76
|
+
group_id: String(data.project_id ?? 0),
|
|
77
|
+
milestone_id: String(data.milestone_id ?? 0),
|
|
70
78
|
task: data.title ?? '',
|
|
79
|
+
review_required: '0',
|
|
71
80
|
description: data.description ?? '',
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
start
|
|
77
|
-
end: data.due_date
|
|
78
|
-
|
|
79
|
-
complete_date: '1901-01-01T00:00:00+00:00',
|
|
80
|
-
estimated_ticks: estimatedTicks,
|
|
81
|
-
task_type: data.task_type ?? 'task',
|
|
82
|
-
isbillable: data.isbillable ?? false,
|
|
83
|
-
creator_id: userId,
|
|
84
|
-
milestone_id: data.milestone_id ?? 0,
|
|
85
|
-
client_id: 0,
|
|
86
|
-
event_id: 0,
|
|
87
|
-
account_id: ctx.accountId,
|
|
88
|
-
percentage: 0,
|
|
89
|
-
attention: false,
|
|
90
|
-
pinned: false,
|
|
91
|
-
assign_all_staff: false,
|
|
92
|
-
agg_cost: 0,
|
|
93
|
-
agg_rate: 0,
|
|
94
|
-
predecessor_id: 0,
|
|
95
|
-
predecessor_complete: false,
|
|
96
|
-
predecessor_name: '',
|
|
97
|
-
review_required: false,
|
|
98
|
-
parts: '',
|
|
99
|
-
staff_assignees: assignees,
|
|
100
|
-
client_assignees: [],
|
|
101
|
-
task_files: [],
|
|
102
|
-
logs: [],
|
|
81
|
+
priority: data.priority || 'low',
|
|
82
|
+
// The signed-in user creates the task. Assignment is separate -- see
|
|
83
|
+
// staff_assignees below and the task_assignee_* tools.
|
|
84
|
+
creator_id: ctx.userId,
|
|
85
|
+
start,
|
|
86
|
+
end: isoDate(data.due_date, start),
|
|
87
|
+
focus: false,
|
|
103
88
|
};
|
|
89
|
+
if (data.notes)
|
|
90
|
+
payload.notes = data.notes;
|
|
91
|
+
if (data.company_id)
|
|
92
|
+
payload.company_id = data.company_id;
|
|
93
|
+
if (data.assigned_to) {
|
|
94
|
+
payload.staff_assignees = [{ user_id: Number(data.assigned_to), attention: false }];
|
|
95
|
+
}
|
|
96
|
+
return payload;
|
|
104
97
|
}
|
|
105
98
|
/**
|
|
106
99
|
* Build update fields for a Group_Task, mapping friendly names to API names.
|
|
@@ -187,6 +180,7 @@ function buildTimeLogPayload(data, ctx) {
|
|
|
187
180
|
};
|
|
188
181
|
}
|
|
189
182
|
const EPOCH = '2000-01-01T00:00:00';
|
|
183
|
+
const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
|
|
190
184
|
function isoDate(value, fallback) {
|
|
191
185
|
if (typeof value === 'string' && value.trim())
|
|
192
186
|
return value;
|
|
@@ -261,6 +255,51 @@ export class ViviScapeClient {
|
|
|
261
255
|
return text;
|
|
262
256
|
}
|
|
263
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Shared response handling for the binary upload paths, which cannot go
|
|
260
|
+
* through request() because that always sends JSON.
|
|
261
|
+
*/
|
|
262
|
+
async send(method, path, init) {
|
|
263
|
+
requireFeature(featureForPath(path), this.creds);
|
|
264
|
+
const url = `${this.baseUrl}${API_PREFIX}/${path.replace(/^\//, '')}`;
|
|
265
|
+
const res = await fetch(url, { method, redirect: 'manual', ...init });
|
|
266
|
+
const text = await res.text();
|
|
267
|
+
if (res.status === 401 || res.status === 403)
|
|
268
|
+
throw new SessionExpiredError();
|
|
269
|
+
if (res.status >= 300 && res.status < 400)
|
|
270
|
+
throw new SessionExpiredError();
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const snippet = text.length > 400 ? `${text.slice(0, 400)}...` : text;
|
|
273
|
+
throw new ApiError(res.status, `API ${method} ${path} returned ${res.status}: ${snippet}`);
|
|
274
|
+
}
|
|
275
|
+
if (!text.trim())
|
|
276
|
+
return null;
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(text);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return text;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
authHeaders(extra = {}) {
|
|
285
|
+
return {
|
|
286
|
+
'Authorization': `Bearer ${this.creds.access_token}`,
|
|
287
|
+
'user_id': String(this.creds.user_id),
|
|
288
|
+
'pid': String(this.creds.pid),
|
|
289
|
+
...extra,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** multipart/form-data upload; fetch sets the boundary, so no Content-Type here. */
|
|
293
|
+
async postMultipart(path, form) {
|
|
294
|
+
return this.send('POST', path, { headers: this.authHeaders(), body: form });
|
|
295
|
+
}
|
|
296
|
+
/** Raw-body upload with metadata in headers (the groups/file/add contract). */
|
|
297
|
+
async postBytes(path, bytes, extra) {
|
|
298
|
+
return this.send('POST', path, {
|
|
299
|
+
headers: this.authHeaders({ 'Content-Type': 'application/octet-stream', ...extra }),
|
|
300
|
+
body: bytes,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
264
303
|
get(path) {
|
|
265
304
|
return this.request('GET', path);
|
|
266
305
|
}
|
|
@@ -371,6 +410,68 @@ export class ViviScapeClient {
|
|
|
371
410
|
async getActiveProjectsByUser(userId) {
|
|
372
411
|
return this.get(`user/groups/active?user_id=${userId || this.userId}&account_id=${this.accountId}`);
|
|
373
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Create a project (group). GEN_Group has ~40 fields; live rows for this
|
|
415
|
+
* tenant show account_id 0, platform_account_id = pid, and group type 5
|
|
416
|
+
* (PROJECT), so those are the defaults here.
|
|
417
|
+
*
|
|
418
|
+
* There is no delete route for projects -- a created project can only be
|
|
419
|
+
* archived or renamed afterward.
|
|
420
|
+
*/
|
|
421
|
+
async addProject(data) {
|
|
422
|
+
return this.post('groups/add', {
|
|
423
|
+
intGroupId: 0,
|
|
424
|
+
intGroupTypeId: GROUP_TYPES.PROJECT,
|
|
425
|
+
intCompanyId: data.company_id,
|
|
426
|
+
intPostPermission: 0,
|
|
427
|
+
intEventId: 0,
|
|
428
|
+
intGroupBeacon: 0,
|
|
429
|
+
decBalance: 0,
|
|
430
|
+
strImage: '',
|
|
431
|
+
strImageThumb: '',
|
|
432
|
+
strGroupName: data.name,
|
|
433
|
+
strGroupShortDescription: data.short_description || '',
|
|
434
|
+
strGroupDescription: data.description || '',
|
|
435
|
+
strGroupLink: '',
|
|
436
|
+
bitActive: true,
|
|
437
|
+
bitPublic: data.is_public ?? false,
|
|
438
|
+
intCapacity: 0,
|
|
439
|
+
intStaff: 0,
|
|
440
|
+
intMembers: 0,
|
|
441
|
+
intPayoutAccountId: 0,
|
|
442
|
+
Clients: [],
|
|
443
|
+
Staff: data.staff && data.staff.length ? data.staff : [this.userId],
|
|
444
|
+
account_id: 0,
|
|
445
|
+
platform_account_id: this.accountId,
|
|
446
|
+
user_id: this.userId,
|
|
447
|
+
status: data.status || 'new',
|
|
448
|
+
enabledWelcome: false,
|
|
449
|
+
enabledGoodbye: false,
|
|
450
|
+
showClients: false,
|
|
451
|
+
welcomeMessage: '',
|
|
452
|
+
goodbyeMessage: '',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/** Update a project by merging changes onto the row the API already has. */
|
|
456
|
+
async updateProject(projectId, changes) {
|
|
457
|
+
const existing = await this.getProjectById(projectId);
|
|
458
|
+
const row = Array.isArray(existing) ? existing[0] : existing;
|
|
459
|
+
if (!row || typeof row !== 'object') {
|
|
460
|
+
throw new ApiError(404, `Project ${projectId} not found, cannot update.`);
|
|
461
|
+
}
|
|
462
|
+
return this.post('groups/update', { ...row, ...changes });
|
|
463
|
+
}
|
|
464
|
+
/** Status-only update; leaves budget and approved time untouched. */
|
|
465
|
+
async setProjectStatus(projectId, status, billable = true) {
|
|
466
|
+
return this.get(`groups/update/status?groupid=${projectId}&status=${encodeURIComponent(status)}&billable=${billable}`);
|
|
467
|
+
}
|
|
468
|
+
async addProjectUser(projectId, userId, opts = {}) {
|
|
469
|
+
return this.get(`groups/user/add?userid=${userId}&groupid=${projectId}` +
|
|
470
|
+
`&iscontact=${opts.is_contact ?? false}&islead=${opts.is_lead ?? false}`);
|
|
471
|
+
}
|
|
472
|
+
async removeProjectUser(projectId, userId) {
|
|
473
|
+
return this.get(`groups/user/remove?groupid=${projectId}&userid=${userId}`);
|
|
474
|
+
}
|
|
374
475
|
// -- Tasks --------------------------------------------------
|
|
375
476
|
async addTask(data) {
|
|
376
477
|
return this.post('tasks/add', buildTaskPayload(data, this.ctx));
|
|
@@ -385,15 +486,19 @@ export class ViviScapeClient {
|
|
|
385
486
|
async getProjectTasks(projectId) {
|
|
386
487
|
return this.get(`tasks/group?group_id=${projectId}`);
|
|
387
488
|
}
|
|
388
|
-
|
|
489
|
+
/**
|
|
490
|
+
* The account/tasks/open route filters server-side on company, ownership, and
|
|
491
|
+
* a date window; pass them through instead of hardcoding "everything".
|
|
492
|
+
*/
|
|
493
|
+
async getOpenTasks(opts = {}) {
|
|
389
494
|
return this.post('account/tasks/open', {
|
|
390
495
|
account_id: this.accountId,
|
|
391
496
|
user_id: this.userId,
|
|
392
|
-
start: EPOCH,
|
|
393
|
-
end: nowIso(),
|
|
394
|
-
company_id: 0,
|
|
395
|
-
teamfilter: '',
|
|
396
|
-
onlymytasks: false,
|
|
497
|
+
start: opts.start || EPOCH,
|
|
498
|
+
end: opts.end || nowIso(),
|
|
499
|
+
company_id: opts.company_id ?? 0,
|
|
500
|
+
teamfilter: opts.team || '',
|
|
501
|
+
onlymytasks: opts.only_mine ?? false,
|
|
397
502
|
});
|
|
398
503
|
}
|
|
399
504
|
async getTask(taskId) {
|
|
@@ -411,6 +516,178 @@ export class ViviScapeClient {
|
|
|
411
516
|
async getCompanyTasks(companyId) {
|
|
412
517
|
return this.get(`company/tasks?company_id=${companyId}&user_id=${this.userId}`);
|
|
413
518
|
}
|
|
519
|
+
// -- Task comments ------------------------------------------
|
|
520
|
+
async getTaskComments(taskId) {
|
|
521
|
+
return this.get(`task/comments/${taskId}`);
|
|
522
|
+
}
|
|
523
|
+
async getTaskComment(commentId) {
|
|
524
|
+
return this.get(`task/comment/id/${commentId}`);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* NOTE: on platform account 1, the backend fires a client-notification webhook
|
|
528
|
+
* for comments on tasks whose client_id > 0. Adding a comment can therefore
|
|
529
|
+
* reach the customer -- the tool description says so too.
|
|
530
|
+
*/
|
|
531
|
+
async addTaskComment(data) {
|
|
532
|
+
return this.post('task/comment/add', {
|
|
533
|
+
comment_id: EMPTY_GUID,
|
|
534
|
+
task_id: data.task_id,
|
|
535
|
+
user_id: this.userId,
|
|
536
|
+
client_id: data.client_id ?? 0,
|
|
537
|
+
creator: this.creds.name || this.creds.email || String(this.userId),
|
|
538
|
+
comment: data.comment,
|
|
539
|
+
attachment_file: '',
|
|
540
|
+
attachment_url: '',
|
|
541
|
+
source: data.source || APP_NAME,
|
|
542
|
+
comment_date: nowIso(),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
async updateTaskComment(data) {
|
|
546
|
+
return this.post('task/comment/update', {
|
|
547
|
+
comment_id: data.comment_id,
|
|
548
|
+
task_id: data.task_id,
|
|
549
|
+
user_id: this.userId,
|
|
550
|
+
client_id: 0,
|
|
551
|
+
creator: this.creds.name || this.creds.email || String(this.userId),
|
|
552
|
+
comment: data.comment,
|
|
553
|
+
attachment_file: '',
|
|
554
|
+
attachment_url: '',
|
|
555
|
+
source: APP_NAME,
|
|
556
|
+
comment_date: nowIso(),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The backend's remove proxy answers 500 even on success: it returns 400 when
|
|
561
|
+
* the upstream delete fails, so a 500 means it crashed deserializing an empty
|
|
562
|
+
* success body (res.data.ToString() on null). Verify by re-reading the thread
|
|
563
|
+
* rather than reporting a failure that did not happen.
|
|
564
|
+
*/
|
|
565
|
+
async removeTaskComment(commentId, taskId) {
|
|
566
|
+
try {
|
|
567
|
+
return await this.get(`task/comment/remove/${commentId}`);
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
if (!(err instanceof ApiError) || err.status !== 500)
|
|
571
|
+
throw err;
|
|
572
|
+
if (!taskId) {
|
|
573
|
+
throw new ApiError(500, `API returned 500 removing comment ${commentId}. This backend answers 500 even when the ` +
|
|
574
|
+
'delete succeeds; pass task_id so the removal can be verified by re-reading the thread.');
|
|
575
|
+
}
|
|
576
|
+
const rows = await this.getTaskComments(taskId);
|
|
577
|
+
const present = Array.isArray(rows)
|
|
578
|
+
&& rows.some((r) => String(r.comment_id).toLowerCase() === commentId.toLowerCase());
|
|
579
|
+
if (present)
|
|
580
|
+
throw err;
|
|
581
|
+
return {
|
|
582
|
+
removed: true,
|
|
583
|
+
comment_id: commentId,
|
|
584
|
+
task_id: taskId,
|
|
585
|
+
note: 'Backend returned 500 while serializing its response; verified removed by re-reading the thread.',
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async markTaskRead(taskId) {
|
|
590
|
+
return this.post('task/read/mark', { task_id: taskId, user_id: this.userId });
|
|
591
|
+
}
|
|
592
|
+
// -- Task assignment and lifecycle --------------------------
|
|
593
|
+
async addTaskAssignee(data) {
|
|
594
|
+
return this.post('tasks/assignee/add', {
|
|
595
|
+
assignee_id: 0,
|
|
596
|
+
task_id: data.task_id,
|
|
597
|
+
user_id: data.user_id,
|
|
598
|
+
client_id: 0,
|
|
599
|
+
active: true,
|
|
600
|
+
leader: data.leader ?? false,
|
|
601
|
+
attention: data.attention ?? false,
|
|
602
|
+
promoting: false,
|
|
603
|
+
pinned: false,
|
|
604
|
+
notify: data.notify ?? true,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/** Takes the assignee_id from the task's staff_assignees, not a user_id. */
|
|
608
|
+
async removeTaskAssignee(assigneeId) {
|
|
609
|
+
return this.get(`tasks/assignee/remove?assignee_id=${assigneeId}`);
|
|
610
|
+
}
|
|
611
|
+
async setTaskLeader(taskId, userId) {
|
|
612
|
+
return this.get(`task/assign/leader/${taskId}/${userId}`);
|
|
613
|
+
}
|
|
614
|
+
async setTaskAttention(taskId, userId, flag) {
|
|
615
|
+
return this.get(`task/assign/attention/${taskId}/${userId}/${flag}`);
|
|
616
|
+
}
|
|
617
|
+
async deleteTask(taskId) {
|
|
618
|
+
return this.get(`tasks/delete?task_id=${taskId}`);
|
|
619
|
+
}
|
|
620
|
+
/** Fold one task into another; the source task is consumed. */
|
|
621
|
+
async mergeTasks(fromTaskId, toTaskId) {
|
|
622
|
+
return this.post('tasks/merge', { task_from_id: fromTaskId, task_to_id: toTaskId });
|
|
623
|
+
}
|
|
624
|
+
// -- Milestones ---------------------------------------------
|
|
625
|
+
async getMilestones(projectId) {
|
|
626
|
+
return this.get(`milestones/list/${projectId}`);
|
|
627
|
+
}
|
|
628
|
+
async getActiveMilestones() {
|
|
629
|
+
return this.get(`milestones/active/account/${this.accountId}`);
|
|
630
|
+
}
|
|
631
|
+
async getMilestonesByCompany(companyId) {
|
|
632
|
+
return this.get(`milestones/account/${this.accountId}/${companyId}`);
|
|
633
|
+
}
|
|
634
|
+
async getMilestonesByUser(userId) {
|
|
635
|
+
return this.get(`milestones/user/active/account/${this.accountId}/${userId || this.userId}`);
|
|
636
|
+
}
|
|
637
|
+
async getMilestone(milestoneId) {
|
|
638
|
+
return this.get(`milestone/id/${milestoneId}`);
|
|
639
|
+
}
|
|
640
|
+
async addMilestone(data) {
|
|
641
|
+
return this.post('milestone/add', buildMilestonePayload(data, this.ctx));
|
|
642
|
+
}
|
|
643
|
+
async updateMilestone(data) {
|
|
644
|
+
if (data.milestone_id) {
|
|
645
|
+
const existing = await this.getMilestone(Number(data.milestone_id));
|
|
646
|
+
const row = Array.isArray(existing) ? existing[0] : existing;
|
|
647
|
+
if (row && typeof row === 'object') {
|
|
648
|
+
return this.post('milestone/update', { ...row, ...buildMilestonePayload(data, this.ctx, row) });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return this.post('milestone/update', buildMilestonePayload(data, this.ctx));
|
|
652
|
+
}
|
|
653
|
+
/** Copy a milestone (and its task template) into a project. */
|
|
654
|
+
async cloneMilestone(milestoneId, projectId, title) {
|
|
655
|
+
const existing = await this.getMilestone(milestoneId);
|
|
656
|
+
const row = (Array.isArray(existing) ? existing[0] : existing);
|
|
657
|
+
if (!row || typeof row !== 'object') {
|
|
658
|
+
throw new ApiError(404, `Milestone ${milestoneId} not found, cannot clone.`);
|
|
659
|
+
}
|
|
660
|
+
return this.post('milestone/clone', {
|
|
661
|
+
...row,
|
|
662
|
+
milestone_id: milestoneId,
|
|
663
|
+
group_id: projectId,
|
|
664
|
+
user_id: this.userId,
|
|
665
|
+
...(title ? { milestone: title } : {}),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Known broken upstream: milestone/remove answers 500 and the milestone
|
|
670
|
+
* survives. The proxy returns 400 with detail when the upstream delete fails,
|
|
671
|
+
* so the 500 is a crash in its own success branch (Convert.ToBoolean on a
|
|
672
|
+
* non-boolean data payload) -- but unlike the comment-remove case the record
|
|
673
|
+
* is genuinely still there. Verify and say so plainly instead of pretending.
|
|
674
|
+
*/
|
|
675
|
+
async removeMilestone(milestoneId) {
|
|
676
|
+
try {
|
|
677
|
+
return await this.get(`milestone/remove/${milestoneId}`);
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
if (!(err instanceof ApiError) || err.status !== 500)
|
|
681
|
+
throw err;
|
|
682
|
+
const still = await this.getMilestone(milestoneId).catch(() => null);
|
|
683
|
+
const row = Array.isArray(still) ? still[0] : still;
|
|
684
|
+
if (row && typeof row === 'object' && row.milestone_id) {
|
|
685
|
+
throw new ApiError(500, `milestone/remove/${milestoneId} returned 500 and the milestone still exists. ` +
|
|
686
|
+
'This route is broken server-side; delete the milestone in the ViviScape Work UI.');
|
|
687
|
+
}
|
|
688
|
+
return { removed: true, milestone_id: milestoneId, note: 'Backend returned 500 but the milestone is gone.' };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
414
691
|
// -- Time logs ----------------------------------------------
|
|
415
692
|
async addTimeLog(data) {
|
|
416
693
|
return this.post('logs/add', buildTimeLogPayload(data, this.ctx));
|
|
@@ -451,6 +728,173 @@ export class ViviScapeClient {
|
|
|
451
728
|
module: 'notes',
|
|
452
729
|
});
|
|
453
730
|
}
|
|
731
|
+
// -- Note relations, tags, revisions -------------------------
|
|
732
|
+
async getAccountNotes(companyId = 0, userId = 0) {
|
|
733
|
+
return this.post('notes/account', {
|
|
734
|
+
account_id: this.accountId,
|
|
735
|
+
company_id: companyId,
|
|
736
|
+
user_id: userId,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
async getNoteRevisions(noteId) {
|
|
740
|
+
return this.get(`note/revisions/${noteId}`);
|
|
741
|
+
}
|
|
742
|
+
async getNoteCompanies(noteId) {
|
|
743
|
+
return this.get(`note/companies/${noteId}`);
|
|
744
|
+
}
|
|
745
|
+
async addNoteCompany(noteId, companyId) {
|
|
746
|
+
return this.post('note/company/add', {
|
|
747
|
+
assignment_id: EMPTY_GUID,
|
|
748
|
+
note_id: noteId,
|
|
749
|
+
assign_user_id: this.userId,
|
|
750
|
+
company_id: companyId,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
async removeNoteCompany(assignmentId) {
|
|
754
|
+
return this.get(`note/company/remove/${assignmentId}`);
|
|
755
|
+
}
|
|
756
|
+
async getNoteUsers(noteId) {
|
|
757
|
+
return this.get(`note/users/${noteId}`);
|
|
758
|
+
}
|
|
759
|
+
async addNoteUser(noteId, userId, permission = 'read') {
|
|
760
|
+
return this.post('note/user/add', {
|
|
761
|
+
assignment_id: EMPTY_GUID,
|
|
762
|
+
note_id: noteId,
|
|
763
|
+
user_id: userId,
|
|
764
|
+
permission,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
async updateNoteUser(assignmentId, noteId, userId, permission) {
|
|
768
|
+
return this.post('note/user/update', {
|
|
769
|
+
assignment_id: assignmentId,
|
|
770
|
+
note_id: noteId,
|
|
771
|
+
user_id: userId,
|
|
772
|
+
permission,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async removeNoteUser(assignmentId) {
|
|
776
|
+
return this.get(`note/user/remove/${assignmentId}`);
|
|
777
|
+
}
|
|
778
|
+
async getNoteTags(noteId) {
|
|
779
|
+
return this.get(`note/tags/${noteId}`);
|
|
780
|
+
}
|
|
781
|
+
async addNoteTag(noteId, tag) {
|
|
782
|
+
return this.post('note/tags/add', { tag_id: EMPTY_GUID, note_id: noteId, tag });
|
|
783
|
+
}
|
|
784
|
+
async removeNoteTag(tagId) {
|
|
785
|
+
return this.get(`note/tag/remove/${tagId}`);
|
|
786
|
+
}
|
|
787
|
+
// -- Note attachments ----------------------------------------
|
|
788
|
+
async getNoteAttachments(noteId) {
|
|
789
|
+
return this.get(`note/attachments/${noteId}`);
|
|
790
|
+
}
|
|
791
|
+
/** Uploads a local file as a note attachment. knowledge=true marks it for extraction. */
|
|
792
|
+
async uploadNoteAttachment(noteId, filePath, knowledge = false) {
|
|
793
|
+
const { readFileSync } = await import('node:fs');
|
|
794
|
+
const { basename } = await import('node:path');
|
|
795
|
+
const form = new FormData();
|
|
796
|
+
form.append('knowledge', String(knowledge));
|
|
797
|
+
form.append('file', new Blob([readFileSync(filePath)]), basename(filePath));
|
|
798
|
+
return this.postMultipart(`note/attachment/upload/${noteId}`, form);
|
|
799
|
+
}
|
|
800
|
+
async removeNoteAttachment(attachmentId) {
|
|
801
|
+
return this.get(`note/attachment/remove/${attachmentId}`);
|
|
802
|
+
}
|
|
803
|
+
/** Flag an attachment as knowledge-base material (drives text extraction). */
|
|
804
|
+
async setNoteAttachmentKnowledge(attachment) {
|
|
805
|
+
return this.post('note/attachment/knowledge', attachment);
|
|
806
|
+
}
|
|
807
|
+
// -- Notebooks -----------------------------------------------
|
|
808
|
+
async getNotebooks(userId = 0) {
|
|
809
|
+
return this.get(`note/books/user/${userId || this.userId}`);
|
|
810
|
+
}
|
|
811
|
+
async getNotebookNotes(bookId) {
|
|
812
|
+
return this.get(`note/book/notes/${bookId}`);
|
|
813
|
+
}
|
|
814
|
+
async addNotebook(name, description = '') {
|
|
815
|
+
return this.post('note/book/add', {
|
|
816
|
+
book_id: EMPTY_GUID,
|
|
817
|
+
name,
|
|
818
|
+
description,
|
|
819
|
+
creator_id: this.userId,
|
|
820
|
+
account_id: this.accountId,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
async updateNotebook(bookId, changes) {
|
|
824
|
+
return this.post('note/book/update', {
|
|
825
|
+
book_id: bookId,
|
|
826
|
+
creator_id: this.userId,
|
|
827
|
+
account_id: this.accountId,
|
|
828
|
+
...changes,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async removeNotebook(bookId) {
|
|
832
|
+
return this.get(`note/book/remove/${bookId}`);
|
|
833
|
+
}
|
|
834
|
+
/** Add or remove notes from a notebook in one call. */
|
|
835
|
+
async assignNotesToBook(bookId, noteIds, assign = true) {
|
|
836
|
+
return this.post('note/book/assign', { book_id: bookId, note_ids: noteIds, assign });
|
|
837
|
+
}
|
|
838
|
+
async getNotebookUsers(bookId) {
|
|
839
|
+
return this.get(`note/book/users/${bookId}`);
|
|
840
|
+
}
|
|
841
|
+
async addNotebookUser(bookId, userId, permission = 'read') {
|
|
842
|
+
return this.post('note/book/user/add', {
|
|
843
|
+
assignment_id: EMPTY_GUID,
|
|
844
|
+
book_id: bookId,
|
|
845
|
+
user_id: userId,
|
|
846
|
+
permission,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
async updateNotebookUser(assignmentId, bookId, userId, permission) {
|
|
850
|
+
return this.post('note/book/user/update', {
|
|
851
|
+
assignment_id: assignmentId,
|
|
852
|
+
book_id: bookId,
|
|
853
|
+
user_id: userId,
|
|
854
|
+
permission,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async removeNotebookUser(assignmentId) {
|
|
858
|
+
return this.get(`note/book/user/remove/${assignmentId}`);
|
|
859
|
+
}
|
|
860
|
+
// -- Project and task files ----------------------------------
|
|
861
|
+
async getProjectFiles(projectId, userId = 0) {
|
|
862
|
+
return this.get(`groups/files?groupid=${projectId}&userid=${userId || this.userId}`);
|
|
863
|
+
}
|
|
864
|
+
async getTaskFiles(taskId) {
|
|
865
|
+
return this.get(`groups/task/files?taskid=${taskId}`);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Upload a file to a project. This route takes the raw bytes as the body and
|
|
869
|
+
* every piece of metadata as a header, and the handler reads each header
|
|
870
|
+
* without a null check -- so all of them must be sent, even when empty.
|
|
871
|
+
*/
|
|
872
|
+
async uploadProjectFile(opts) {
|
|
873
|
+
const { readFileSync } = await import('node:fs');
|
|
874
|
+
const { basename } = await import('node:path');
|
|
875
|
+
const name = basename(opts.file_path);
|
|
876
|
+
return this.postBytes('groups/file/add', readFileSync(opts.file_path), {
|
|
877
|
+
userid: String(this.userId),
|
|
878
|
+
groupid: String(opts.project_id),
|
|
879
|
+
filename: name,
|
|
880
|
+
title: opts.title || name,
|
|
881
|
+
youtube: '',
|
|
882
|
+
notify: String(opts.notify ?? false),
|
|
883
|
+
taskid: String(opts.task_id ?? 0),
|
|
884
|
+
lat: '0',
|
|
885
|
+
lng: '0',
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
async updateProjectFile(file) {
|
|
889
|
+
return this.post('groups/file/update', file);
|
|
890
|
+
}
|
|
891
|
+
async deleteProjectFile(fileId) {
|
|
892
|
+
return this.get(`groups/file/delete?fileid=${fileId}`);
|
|
893
|
+
}
|
|
894
|
+
/** Returns whatever the download route serves; usually a redirect target or URL. */
|
|
895
|
+
async downloadProjectFile(fileId) {
|
|
896
|
+
return this.get(`groups/file/download/${fileId}`);
|
|
897
|
+
}
|
|
454
898
|
// -- Account ------------------------------------------------
|
|
455
899
|
async getAccountInfo() {
|
|
456
900
|
return this.get(`account/id/${this.accountId}`);
|
package/dist/auth/permissions.js
CHANGED