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.
@@ -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 now = new Date().toISOString();
38
- const assignees = [];
39
- const userId = data.assigned_to ?? ctx.userId;
40
- assignees.push({
41
- assignee_id: 0,
42
- task_id: 0,
43
- user_id: userId,
44
- client_id: 0,
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
- notes: data.notes ?? '',
73
- status: data.status ?? 'new',
74
- priority: priorityStr,
75
- priority_rank: priorityRank,
76
- start: data.start_date ?? now,
77
- end: data.due_date ?? now,
78
- creation_date: now,
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
- async getOpenTasks() {
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}`);
@@ -62,6 +62,8 @@ export function featureForPath(path) {
62
62
  return 'logs';
63
63
  if (p.startsWith('prospect'))
64
64
  return 'crm';
65
+ if (p.startsWith('task/comment'))
66
+ return 'comment';
65
67
  if (p.startsWith('task'))
66
68
  return 'tasks';
67
69
  return 'auth';