@managemint-solutions/sdk 0.42.0 → 0.44.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.
@@ -15,9 +15,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.SupportingFilesResource = void 0;
18
- const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
18
+ const enum_1 = require("@managemint-solutions/entities/supporting-files/enum");
19
+ const enum_2 = require("@managemint-solutions/entities/audit-trail/enum");
19
20
  const audit_1 = require("../audit");
20
21
  const errors_1 = require("../errors");
22
+ const won_1 = require("../leads/won");
21
23
  const paths_1 = require("./paths");
22
24
  __exportStar(require("./dto"), exports);
23
25
  __exportStar(require("./paths"), exports);
@@ -45,7 +47,7 @@ class SupportingFilesResource {
45
47
  this.auth = auth;
46
48
  this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
47
49
  table: 'supporting_files',
48
- entityType: enum_1.AuditTrailEntityType.SUPPORTING_FILES,
50
+ entityType: enum_2.AuditTrailEntityType.SUPPORTING_FILES,
49
51
  });
50
52
  }
51
53
  async getPresignedUrl(supportingFileId) {
@@ -77,6 +79,9 @@ class SupportingFilesResource {
77
79
  return data;
78
80
  }
79
81
  async upload(entity, entityId, input) {
82
+ if (entity === enum_1.SupportingFilesEntityTypes.LEAD) {
83
+ await (0, won_1.refuseWonLead)(this.supabase, this.auth.organization_id, entityId);
84
+ }
80
85
  const { data: existing, error: existingError } = await this.supabase
81
86
  .from('supporting_files')
82
87
  .select('name, extension')
@@ -1,7 +1,16 @@
1
- import type { CreateTaskDto as CreateTaskDtoType, GetTasksDto as GetTasksDtoType, TaskIdDto as TaskIdDtoType, UpdateTaskDto as UpdateTaskDtoType } from '@managemint-solutions/entities/tasks/dto';
1
+ import type { AddTaskLinkDto as AddTaskLinkDtoType, CreateTaskDto as CreateTaskDtoType, GetTasksDto as GetTasksDtoType, TaskIdDto as TaskIdDtoType, TaskLinkIdDto as TaskLinkIdDtoType, UpdateTaskDto as UpdateTaskDtoType } from '@managemint-solutions/entities/tasks/dto';
2
+ import { TaskLinkType } from '@managemint-solutions/entities/tasks/enum';
2
3
  export declare class TaskIdDto implements TaskIdDtoType {
3
4
  readonly taskId: string;
4
5
  }
6
+ export declare class TaskLinkIdDto implements TaskLinkIdDtoType {
7
+ readonly taskId: string;
8
+ readonly linkedTaskId: string;
9
+ }
10
+ export declare class AddTaskLinkDto implements AddTaskLinkDtoType {
11
+ readonly linked_task_id: string;
12
+ readonly link_type: TaskLinkType;
13
+ }
5
14
  export declare class GetTasksDto implements Omit<GetTasksDtoType, 'status_id_in' | 'status_id_not_in' | 'label_id_in' | 'priority_in'> {
6
15
  readonly search?: string | null;
7
16
  readonly page: number;
package/dist/tasks/dto.js CHANGED
@@ -9,9 +9,10 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.UpdateTaskDto = exports.CreateTaskDto = exports.GetTasksDto = exports.TaskIdDto = void 0;
12
+ exports.UpdateTaskDto = exports.CreateTaskDto = exports.GetTasksDto = exports.AddTaskLinkDto = exports.TaskLinkIdDto = exports.TaskIdDto = void 0;
13
13
  const class_validator_1 = require("class-validator");
14
14
  const class_transformer_1 = require("class-transformer");
15
+ const enum_1 = require("@managemint-solutions/entities/tasks/enum");
15
16
  const transforms_1 = require("../transforms");
16
17
  const validators_1 = require("../validators");
17
18
  // class-validator versions of the shared DTO types from @managemint-solutions/entities, so the
@@ -25,6 +26,33 @@ __decorate([
25
26
  ,
26
27
  __metadata("design:type", String)
27
28
  ], TaskIdDto.prototype, "taskId", void 0);
29
+ class TaskLinkIdDto {
30
+ taskId;
31
+ linkedTaskId;
32
+ }
33
+ exports.TaskLinkIdDto = TaskLinkIdDto;
34
+ __decorate([
35
+ (0, class_validator_1.IsUUID)('7', { message: 'Invalid task ID format' }),
36
+ __metadata("design:type", String)
37
+ ], TaskLinkIdDto.prototype, "taskId", void 0);
38
+ __decorate([
39
+ (0, class_validator_1.IsUUID)('7', { message: 'Invalid linked task ID format' }),
40
+ __metadata("design:type", String)
41
+ ], TaskLinkIdDto.prototype, "linkedTaskId", void 0);
42
+ class AddTaskLinkDto {
43
+ linked_task_id;
44
+ // The only check on the value: `task_links.link_type` is plain text.
45
+ link_type;
46
+ }
47
+ exports.AddTaskLinkDto = AddTaskLinkDto;
48
+ __decorate([
49
+ (0, class_validator_1.IsUUID)('7', { message: 'Invalid linked task ID format' }),
50
+ __metadata("design:type", String)
51
+ ], AddTaskLinkDto.prototype, "linked_task_id", void 0);
52
+ __decorate([
53
+ (0, class_validator_1.IsEnum)(enum_1.TaskLinkType, { message: 'Link type must be child or related' }),
54
+ __metadata("design:type", String)
55
+ ], AddTaskLinkDto.prototype, "link_type", void 0);
28
56
  // `status_id_in`, `status_id_not_in`, `label_id_in` and `priority_in` are omitted from the
29
57
  // implemented type on purpose: the entities type carries the CSV wire values, while this class
30
58
  // carries the `@Transform`-parsed arrays the query builder feeds to `.in('status_id', ...)` /
@@ -3,7 +3,7 @@ import type { TaskEntity, TaskSummary } from '@managemint-solutions/entities/tas
3
3
  import type { AuthContext } from '../auth';
4
4
  import { type AuditResource } from '../audit';
5
5
  import type { TypedSupabaseClient } from '../client';
6
- import type { CreateTaskDto, GetTasksDto, UpdateTaskDto } from './dto';
6
+ import type { AddTaskLinkDto, CreateTaskDto, GetTasksDto, UpdateTaskDto } from './dto';
7
7
  export * from './dto';
8
8
  export declare class TasksResource {
9
9
  private readonly supabase;
@@ -20,6 +20,20 @@ export declare class TasksResource {
20
20
  restore(taskId: string): Promise<TaskEntity>;
21
21
  /** Soft delete - the row stays, flagged `deleted`, and `restore()` brings it back. */
22
22
  remove(taskId: string): Promise<void>;
23
+ /**
24
+ * Links `input.linked_task_id` to the task as a subtask or as related. The rules live here,
25
+ * checked before the insert: not the task itself, both tasks this organization's and not
26
+ * deleted, and no loop of subtasks. The two a concurrent write could still break - one link
27
+ * per pair, one parent per task - are unique indexes, and their violations are turned into
28
+ * sentences. The link has no audit of its own: it is one UPDATE entry on the task it was
29
+ * made from.
30
+ */
31
+ linkTask(taskId: string, input: AddTaskLinkDto): Promise<TaskEntity>;
32
+ /**
33
+ * Removes the link between the two tasks, whichever of them it was made from - a pair carries
34
+ * one link at most. Unlinked from the subtask's side, the entry names the other task as parent.
35
+ */
36
+ unlinkTask(taskId: string, linkedTaskId: string): Promise<TaskEntity>;
23
37
  /**
24
38
  * Refuses, before anything is written, a label id that is not one of this organization's task
25
39
  * labels - one deleted while the form was open, or another organization's. The `task_labels`
@@ -15,8 +15,9 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.TasksResource = void 0;
18
- const enum_1 = require("@managemint-solutions/entities/labels/enum");
19
- const enum_2 = require("@managemint-solutions/entities/audit-trail/enum");
18
+ const enum_1 = require("@managemint-solutions/entities/tasks/enum");
19
+ const enum_2 = require("@managemint-solutions/entities/labels/enum");
20
+ const enum_3 = require("@managemint-solutions/entities/audit-trail/enum");
20
21
  const audit_1 = require("../audit");
21
22
  const errors_1 = require("../errors");
22
23
  const transforms_1 = require("../transforms");
@@ -36,7 +37,8 @@ const TASK_LIST_SELECT = `
36
37
  label,
37
38
  is_complete,
38
39
  is_fail,
39
- is_triage
40
+ is_triage,
41
+ is_won
40
42
  ),
41
43
  priority,
42
44
  labels:labels!task_labels (
@@ -49,6 +51,15 @@ const TASK_LIST_SELECT = `
49
51
  project:project_profiles!project_id (*),
50
52
  client:client_profiles!client_id (*)
51
53
  `;
54
+ // A linked task as the detail read shows it. `deleted` is read only so a soft-deleted task can be
55
+ // left out of the links; it is not part of LinkedTask.
56
+ const LINKED_TASK_FIELDS = `
57
+ mms_id,
58
+ task_number:task_id,
59
+ name,
60
+ priority,
61
+ deleted,
62
+ status:statuses!status_id ( mms_id, colour, label, is_complete, is_fail, is_triage, is_won )`;
52
63
  const TASK_DETAIL_SELECT = `
53
64
  mms_id,
54
65
  created_at,
@@ -66,7 +77,8 @@ const TASK_DETAIL_SELECT = `
66
77
  label,
67
78
  is_complete,
68
79
  is_fail,
69
- is_triage
80
+ is_triage,
81
+ is_won
70
82
  ),
71
83
  priority,
72
84
  labels:labels!task_labels (
@@ -87,7 +99,15 @@ const TASK_DETAIL_SELECT = `
87
99
  deleted_at,
88
100
  deleted_by:user_profiles!deleted_by (*),
89
101
  restored_at,
90
- restored_by:user_profiles!restored_by (*)
102
+ restored_by:user_profiles!restored_by (*),
103
+ links_out:task_links!task_id (
104
+ link_type,
105
+ task:tasks!linked_task_id ( ${LINKED_TASK_FIELDS} )
106
+ ),
107
+ links_in:task_links!linked_task_id (
108
+ link_type,
109
+ task:tasks!task_id ( ${LINKED_TASK_FIELDS} )
110
+ )
91
111
  `;
92
112
  // Added to the list select only when `label_id_in` is set: an inner join on the join table is how
93
113
  // PostgREST filters a task by the labels it carries.
@@ -95,6 +115,12 @@ const LABEL_FILTER_SELECT = 'task_labels!inner ( label_id )';
95
115
  // A `task_labels` row as `setLabels` reads and writes it: the label id, and the name the audit
96
116
  // entry records.
97
117
  const PINNED_LABEL_SELECT = 'label_id, label:labels ( name )';
118
+ const linkedTasks = (rows, linkType) => rows
119
+ .filter((row) => row.link_type === linkType && row.task && !row.task.deleted)
120
+ .map((row) => {
121
+ const { deleted: _deleted, ...task } = row.task;
122
+ return task;
123
+ });
98
124
  class TasksResource {
99
125
  supabase;
100
126
  auth;
@@ -106,7 +132,7 @@ class TasksResource {
106
132
  this.audit = audit;
107
133
  this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
108
134
  table: 'tasks',
109
- entityType: enum_2.AuditTrailEntityType.TASKS,
135
+ entityType: enum_3.AuditTrailEntityType.TASKS,
110
136
  });
111
137
  }
112
138
  async list(query) {
@@ -196,10 +222,20 @@ class TasksResource {
196
222
  .select(TASK_DETAIL_SELECT)
197
223
  .eq('mms_id', taskId)
198
224
  .eq('organization_id', this.auth.organization_id)
225
+ .order('created_at', { referencedTable: 'links_out' })
226
+ .order('created_at', { referencedTable: 'links_in' })
199
227
  .single();
200
228
  if (error)
201
229
  throw (0, errors_1.mapPostgrestError)(error);
202
- return data;
230
+ // Related is stored once, so it is read from both directions; a child link is the parent
231
+ // from the child's side and a subtask from the parent's.
232
+ const { links_out: linksOut, links_in: linksIn, ...task } = data;
233
+ return {
234
+ ...task,
235
+ parent: linkedTasks(linksIn, enum_1.TaskLinkType.CHILD)[0] ?? null,
236
+ subtasks: linkedTasks(linksOut, enum_1.TaskLinkType.CHILD),
237
+ related: [...linkedTasks(linksOut, enum_1.TaskLinkType.RELATED), ...linkedTasks(linksIn, enum_1.TaskLinkType.RELATED)],
238
+ };
203
239
  }
204
240
  async create(input) {
205
241
  // `label_ids` is not a tasks column: the labels are join rows, written after the task.
@@ -237,20 +273,126 @@ class TasksResource {
237
273
  return this.getById(taskId);
238
274
  }
239
275
  async archive(taskId) {
240
- await this.table.transition(taskId, enum_2.AuditTrailAction.ARCHIVE, (0, transforms_1.archivedPatch)(this.auth.mms_id));
276
+ await this.table.transition(taskId, enum_3.AuditTrailAction.ARCHIVE, (0, transforms_1.archivedPatch)(this.auth.mms_id));
241
277
  return this.getById(taskId);
242
278
  }
243
279
  async unarchive(taskId) {
244
- await this.table.transition(taskId, enum_2.AuditTrailAction.UNARCHIVE, (0, transforms_1.unarchivedPatch)(this.auth.mms_id));
280
+ await this.table.transition(taskId, enum_3.AuditTrailAction.UNARCHIVE, (0, transforms_1.unarchivedPatch)(this.auth.mms_id));
245
281
  return this.getById(taskId);
246
282
  }
247
283
  async restore(taskId) {
248
- await this.table.transition(taskId, enum_2.AuditTrailAction.RESTORE, (0, transforms_1.restoredPatch)(this.auth.mms_id));
284
+ await this.table.transition(taskId, enum_3.AuditTrailAction.RESTORE, (0, transforms_1.restoredPatch)(this.auth.mms_id));
249
285
  return this.getById(taskId);
250
286
  }
251
287
  /** Soft delete - the row stays, flagged `deleted`, and `restore()` brings it back. */
252
288
  async remove(taskId) {
253
- await this.table.transition(taskId, enum_2.AuditTrailAction.DELETE, (0, transforms_1.deletedPatch)(this.auth.mms_id));
289
+ await this.table.transition(taskId, enum_3.AuditTrailAction.DELETE, (0, transforms_1.deletedPatch)(this.auth.mms_id));
290
+ }
291
+ /**
292
+ * Links `input.linked_task_id` to the task as a subtask or as related. The rules live here,
293
+ * checked before the insert: not the task itself, both tasks this organization's and not
294
+ * deleted, and no loop of subtasks. The two a concurrent write could still break - one link
295
+ * per pair, one parent per task - are unique indexes, and their violations are turned into
296
+ * sentences. The link has no audit of its own: it is one UPDATE entry on the task it was
297
+ * made from.
298
+ */
299
+ async linkTask(taskId, input) {
300
+ const refuse = (status, message, cause) => new errors_1.SupabaseClientError({ status, error: 'Database Error', message }, { cause });
301
+ if (input.linked_task_id === taskId) {
302
+ throw refuse(400, 'A task cannot be linked to itself');
303
+ }
304
+ const { data: tasks, error: tasksError } = await this.supabase
305
+ .from('tasks')
306
+ .select('mms_id, task_number:task_id')
307
+ .eq('organization_id', this.auth.organization_id)
308
+ .eq('deleted', false)
309
+ .in('mms_id', [taskId, input.linked_task_id]);
310
+ if (tasksError)
311
+ throw (0, errors_1.mapPostgrestError)(tasksError);
312
+ const linked = tasks.find((task) => task.mms_id === input.linked_task_id);
313
+ if (tasks.length !== 2 || !linked) {
314
+ throw refuse(400, 'Invalid task for this organization');
315
+ }
316
+ if (input.link_type === enum_1.TaskLinkType.CHILD) {
317
+ // Walk up from the new parent: were the new subtask among its ancestors, the link would
318
+ // close a loop. The chain is a few links deep. `seen` stops the walk should two concurrent
319
+ // links ever have made a loop after all, rather than reading forever.
320
+ const seen = new Set();
321
+ let ancestor = taskId;
322
+ while (ancestor && !seen.has(ancestor)) {
323
+ const child = ancestor;
324
+ seen.add(child);
325
+ const { data: parentLink, error: parentError } = await this.supabase
326
+ .from('task_links')
327
+ .select('task_id')
328
+ .eq('organization_id', this.auth.organization_id)
329
+ .eq('linked_task_id', child)
330
+ .eq('link_type', enum_1.TaskLinkType.CHILD)
331
+ .maybeSingle();
332
+ if (parentError)
333
+ throw (0, errors_1.mapPostgrestError)(parentError);
334
+ ancestor = parentLink?.task_id ?? null;
335
+ if (ancestor === input.linked_task_id) {
336
+ throw refuse(400, 'A task cannot be a subtask of its own subtask');
337
+ }
338
+ }
339
+ }
340
+ const { error } = await this.supabase.from('task_links').insert({
341
+ task_id: taskId,
342
+ linked_task_id: input.linked_task_id,
343
+ link_type: input.link_type,
344
+ organization_id: this.auth.organization_id,
345
+ created_by: this.auth.mms_id,
346
+ });
347
+ if (error) {
348
+ // Postgres names the violated index: the primary key and the pair index both mean the two
349
+ // tasks are linked already.
350
+ if (error.code === '23505' && error.message.includes('uq_task_links_one_parent')) {
351
+ throw refuse(409, 'That task already has a parent', error);
352
+ }
353
+ if (error.code === '23505') {
354
+ throw refuse(409, 'These tasks are already linked', error);
355
+ }
356
+ throw (0, errors_1.mapPostgrestError)(error);
357
+ }
358
+ const role = input.link_type === enum_1.TaskLinkType.CHILD ? 'subtask' : 'related';
359
+ await this.audit.record({
360
+ action: enum_3.AuditTrailAction.UPDATE,
361
+ entityType: enum_3.AuditTrailEntityType.TASKS,
362
+ entityId: taskId,
363
+ changes: { linked_task: { old: null, new: `TASK-${linked.task_number} (${role})` } },
364
+ });
365
+ return this.getById(taskId);
366
+ }
367
+ /**
368
+ * Removes the link between the two tasks, whichever of them it was made from - a pair carries
369
+ * one link at most. Unlinked from the subtask's side, the entry names the other task as parent.
370
+ */
371
+ async unlinkTask(taskId, linkedTaskId) {
372
+ // Both ids are `@IsUUID('7')`-validated by TaskLinkIdDto, so interpolating them is safe.
373
+ const { data, error } = await this.supabase
374
+ .from('task_links')
375
+ .delete()
376
+ .eq('organization_id', this.auth.organization_id)
377
+ .or(`and(task_id.eq.${taskId},linked_task_id.eq.${linkedTaskId}),and(task_id.eq.${linkedTaskId},linked_task_id.eq.${taskId})`)
378
+ .select('task_id, link_type, from:tasks!task_id ( task_number:task_id ), to:tasks!linked_task_id ( task_number:task_id )');
379
+ if (error)
380
+ throw (0, errors_1.mapPostgrestError)(error);
381
+ const [row] = data;
382
+ if (!row) {
383
+ throw new errors_1.SupabaseClientError({ status: 404, error: 'Database Error', message: 'Link not found' });
384
+ }
385
+ const madeHere = row.task_id === taskId;
386
+ const role = row.link_type === enum_1.TaskLinkType.RELATED ? 'related' : madeHere ? 'subtask' : 'parent';
387
+ await this.audit.record({
388
+ action: enum_3.AuditTrailAction.UPDATE,
389
+ entityType: enum_3.AuditTrailEntityType.TASKS,
390
+ entityId: taskId,
391
+ changes: {
392
+ linked_task: { old: `TASK-${(madeHere ? row.to : row.from).task_number} (${role})`, new: null },
393
+ },
394
+ });
395
+ return this.getById(taskId);
254
396
  }
255
397
  /**
256
398
  * Refuses, before anything is written, a label id that is not one of this organization's task
@@ -265,7 +407,7 @@ class TasksResource {
265
407
  .from('labels')
266
408
  .select('mms_id', { count: 'exact', head: true })
267
409
  .eq('organization_id', this.auth.organization_id)
268
- .eq('entity_type', enum_1.LabelEntityTypes.TASK)
410
+ .eq('entity_type', enum_2.LabelEntityTypes.TASK)
269
411
  .in('mms_id', labelIds);
270
412
  if (error)
271
413
  throw (0, errors_1.mapPostgrestError)(error);
@@ -326,8 +468,8 @@ class TasksResource {
326
468
  throw (0, errors_1.mapPostgrestError)(error);
327
469
  }
328
470
  await this.audit.record({
329
- action: enum_2.AuditTrailAction.UPDATE,
330
- entityType: enum_2.AuditTrailEntityType.TASKS,
471
+ action: enum_3.AuditTrailAction.UPDATE,
472
+ entityType: enum_3.AuditTrailEntityType.TASKS,
331
473
  entityId: taskId,
332
474
  changes: {
333
475
  labels: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@managemint-solutions/sdk",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Typed Supabase data-access SDK for ManageMint Solutions",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Scott Bebington <scottbebington@gmail.com>",
@@ -51,7 +51,7 @@
51
51
  "testEnvironment": "node"
52
52
  },
53
53
  "dependencies": {
54
- "@managemint-solutions/entities": "^1.25.0"
54
+ "@managemint-solutions/entities": "^1.27.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@nestjs/common": "^11.0.0",