@managemint-solutions/sdk 0.41.0 → 0.43.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 CHANGED
@@ -202,10 +202,13 @@ await supabase.tasks.archive(task.mms_id);
202
202
  await supabase.tasks.unarchive(task.mms_id);
203
203
  await supabase.tasks.remove(task.mms_id); // soft delete
204
204
  await supabase.tasks.restore(task.mms_id);
205
+ await supabase.tasks.linkTask(task.mms_id, { linked_task_id: otherId, link_type: TaskLinkType.CHILD });
206
+ await supabase.tasks.unlinkTask(task.mms_id, otherId);
205
207
  ```
206
208
 
207
209
  A task's `task_id` (its per-organization number) is assigned by the database on insert, and each
208
- task carries its embedded `status`, `assigned_to`, `project` and `client`.
210
+ task carries its embedded `status`, `assigned_to`, `project` and `client`. The detail read also carries its `parent`,
211
+ `subtasks` and `related` tasks, read from `task_links` in both directions.
209
212
 
210
213
  The client decodes `{ mms_id, organization_id }` from the JWT itself and scopes every query
211
214
  by `organization_id`. `supabase.supabase` is the raw `@supabase/supabase-js` client
package/dist/client.d.ts CHANGED
@@ -8,6 +8,7 @@ import { NotesResource } from './notes';
8
8
  import { NotificationsResource } from './notifications';
9
9
  import { BillingReadsResource } from './billing';
10
10
  import { InvoicesReadsResource } from './billing/invoices';
11
+ import { LabelsResource } from './labels';
11
12
  import { LeaveEntitlementsResource, LeaveRequestsResource, LeaveTypesResource } from './leave';
12
13
  import { OrganizationsResource } from './organizations';
13
14
  import { PermissionsResource } from './permissions';
@@ -47,6 +48,7 @@ export declare class SupabaseClient {
47
48
  readonly audit: AuditResource;
48
49
  readonly configs: ConfigsResource;
49
50
  readonly statuses: StatusesResource;
51
+ readonly labels: LabelsResource;
50
52
  readonly additionalDetails: AdditionalDetailsResource;
51
53
  readonly notes: NotesResource;
52
54
  readonly notifications: NotificationsResource;
package/dist/client.js CHANGED
@@ -10,6 +10,7 @@ const notes_1 = require("./notes");
10
10
  const notifications_1 = require("./notifications");
11
11
  const billing_1 = require("./billing");
12
12
  const invoices_1 = require("./billing/invoices");
13
+ const labels_1 = require("./labels");
13
14
  const leave_1 = require("./leave");
14
15
  const organizations_1 = require("./organizations");
15
16
  const permissions_1 = require("./permissions");
@@ -32,6 +33,7 @@ class SupabaseClient {
32
33
  // Reads run under the authenticated grant; the writes are the service client's.
33
34
  configs;
34
35
  statuses;
36
+ labels;
35
37
  additionalDetails;
36
38
  notes;
37
39
  notifications;
@@ -58,6 +60,7 @@ class SupabaseClient {
58
60
  this.audit = new audit_1.AuditResource(this.supabase, this.auth, config.onAuditError);
59
61
  this.configs = new configs_1.ConfigsResource(this.supabase);
60
62
  this.statuses = new statuses_1.StatusesResource(this.supabase, this.auth, this.audit);
63
+ this.labels = new labels_1.LabelsResource(this.supabase, this.auth, this.audit);
61
64
  this.additionalDetails = new additional_details_1.AdditionalDetailsResource(this.supabase, this.auth, this.audit);
62
65
  this.notes = new notes_1.NotesResource(this.supabase, this.auth, this.audit);
63
66
  this.notifications = new notifications_1.NotificationsResource(this.supabase, this.auth, this.audit);
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export { CreateFeatureRequestDto } from './feature-requests';
18
18
  export { CreateConfigDto, UpdateConfigDto } from './configs';
19
19
  export { CreateSupportRequestDto } from './support';
20
20
  export { AddStatusDto, GetStatusesDto, StatusIdDto, UpdateStatusDto } from './statuses';
21
+ export { AddLabelDto, GetLabelsDto, LabelIdDto, UpdateLabelDto } from './labels';
21
22
  export { AddAdditionalDetailsDto, AdditionalDetailsIdDto, GetAdditionalDetailsDto, IsAdditionalDetailsValueByType, UpdateAdditionalDetailsDto, } from './additional-details';
22
23
  export { AddNoteDto, GetNotesDto, NoteIdDto } from './notes';
23
24
  export type { CreateNotificationInput, CreatedNotification, NotificationContentRow, OrganizationTableFactory, QueuedNotification, } from './notifications';
@@ -35,7 +36,7 @@ export type { ClaimOutcome, WebhookEventRow, WebhookEventsTable } from './webhoo
35
36
  export { GetSupportingFilesDto, SupportingFileIdDto } from './supporting-files';
36
37
  export { ClientIdDto, CreateClientDto, GetClientsDto, UpdateClientDto } from './clients';
37
38
  export { CreateProjectDto, GetProjectsDto, ProjectIdDto, UpdateProjectDto } from './projects';
38
- export { CreateTaskDto, GetTasksDto, TaskIdDto, UpdateTaskDto } from './tasks';
39
+ export { AddTaskLinkDto, CreateTaskDto, GetTasksDto, TaskIdDto, TaskLinkIdDto, UpdateTaskDto } from './tasks';
39
40
  export type { CreateLeaveEntitlementInput, CreateLeaveRequestRow, LeaveEntitlementCover, ReviewLeaveRequestRow, UpdateLeaveRequestRow, } from './leave';
40
41
  export { CreateLeaveEntitlementDto, CreateLeaveRequestDto, CreateLeaveTypeDto, GetLeaveEntitlementsDto, GetLeaveRequestsDto, GetLeaveTypesDto, LeaveEntitlementIdDto, LeaveRequestIdDto, LeaveTypeIdDto, ReviewLeaveRequestDto, UpdateLeaveEntitlementDto, UpdateLeaveRequestDto, UpdateLeaveTypeDto, } from './leave';
41
42
  export { GetPublicHolidaysDto } from './public-holidays';
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@
7
7
  // their classes are not exported: a consumer never constructs one, and every export is a contract
8
8
  // to keep. The row and input types they produce and accept are exported for annotations.
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.UserIdDto = exports.UpdateUserDto = exports.UpdateMyProfileDto = exports.GetUsersQueryDto = exports.AddUserDto = exports.UpdatePermissionsDto = exports.PermissionsResource = exports.UpdateOrganizationDto = exports.CreateOrganizationDto = exports.NotificationIdDto = exports.GetNotificationsDto = exports.NoteIdDto = exports.GetNotesDto = exports.AddNoteDto = exports.UpdateAdditionalDetailsDto = exports.IsAdditionalDetailsValueByType = exports.GetAdditionalDetailsDto = exports.AdditionalDetailsIdDto = exports.AddAdditionalDetailsDto = exports.UpdateStatusDto = exports.StatusIdDto = exports.GetStatusesDto = exports.AddStatusDto = exports.CreateSupportRequestDto = exports.UpdateConfigDto = exports.CreateConfigDto = exports.CreateFeatureRequestDto = exports.InvoiceIdDto = exports.GetInvoicesQueryDto = exports.ReinstateSubscriptionDto = exports.PurchaseModuleDto = exports.PaymentIdDto = exports.InitialPaymentDto = exports.GetPaymentsQueryDto = exports.CancelSubscriptionDto = exports.GetAuditTrailDto = exports.IsNullable = exports.EmptyToNull = exports.SupabaseClientError = exports.createSupabaseServiceClient = exports.SupabaseServiceClient = exports.SignInDto = exports.SendPasswordResetEmailDto = exports.ResetPasswordDto = exports.ExchangeTokenHashDto = exports.ExchangeRefreshTokenDto = exports.createSupabaseAuthClient = exports.SupabaseAuthClient = exports.createSupabaseClient = exports.SupabaseClient = void 0;
11
- exports.monthlyTotalMinor = exports.weekStartOf = exports.minutesBetween = exports.UpdateTimesheetEntryDto = exports.TimesheetIdDto = exports.TimesheetEntryIdDto = exports.ReviewTimesheetDto = exports.GetTimesheetsDto = exports.GetTimesheetReportDto = exports.CreateTimesheetEntryDto = exports.todayInJohannesburg = exports.shiftDays = exports.leavePeriodFor = exports.isWithinPeriod = exports.countWorkingDays = exports.addOneYearMinusDay = exports.GetPublicHolidaysDto = exports.UpdateLeaveTypeDto = exports.UpdateLeaveRequestDto = exports.UpdateLeaveEntitlementDto = exports.ReviewLeaveRequestDto = exports.LeaveTypeIdDto = exports.LeaveRequestIdDto = exports.LeaveEntitlementIdDto = exports.GetLeaveTypesDto = exports.GetLeaveRequestsDto = exports.GetLeaveEntitlementsDto = exports.CreateLeaveTypeDto = exports.CreateLeaveRequestDto = exports.CreateLeaveEntitlementDto = exports.UpdateTaskDto = exports.TaskIdDto = exports.GetTasksDto = exports.CreateTaskDto = exports.UpdateProjectDto = exports.ProjectIdDto = exports.GetProjectsDto = exports.CreateProjectDto = exports.UpdateClientDto = exports.GetClientsDto = exports.CreateClientDto = exports.ClientIdDto = exports.SupportingFileIdDto = exports.GetSupportingFilesDto = void 0;
10
+ exports.AddUserDto = exports.UpdatePermissionsDto = exports.PermissionsResource = exports.UpdateOrganizationDto = exports.CreateOrganizationDto = exports.NotificationIdDto = exports.GetNotificationsDto = exports.NoteIdDto = exports.GetNotesDto = exports.AddNoteDto = exports.UpdateAdditionalDetailsDto = exports.IsAdditionalDetailsValueByType = exports.GetAdditionalDetailsDto = exports.AdditionalDetailsIdDto = exports.AddAdditionalDetailsDto = exports.UpdateLabelDto = exports.LabelIdDto = exports.GetLabelsDto = exports.AddLabelDto = exports.UpdateStatusDto = exports.StatusIdDto = exports.GetStatusesDto = exports.AddStatusDto = exports.CreateSupportRequestDto = exports.UpdateConfigDto = exports.CreateConfigDto = exports.CreateFeatureRequestDto = exports.InvoiceIdDto = exports.GetInvoicesQueryDto = exports.ReinstateSubscriptionDto = exports.PurchaseModuleDto = exports.PaymentIdDto = exports.InitialPaymentDto = exports.GetPaymentsQueryDto = exports.CancelSubscriptionDto = exports.GetAuditTrailDto = exports.IsNullable = exports.EmptyToNull = exports.SupabaseClientError = exports.createSupabaseServiceClient = exports.SupabaseServiceClient = exports.SignInDto = exports.SendPasswordResetEmailDto = exports.ResetPasswordDto = exports.ExchangeTokenHashDto = exports.ExchangeRefreshTokenDto = exports.createSupabaseAuthClient = exports.SupabaseAuthClient = exports.createSupabaseClient = exports.SupabaseClient = void 0;
11
+ exports.monthlyTotalMinor = exports.weekStartOf = exports.minutesBetween = exports.UpdateTimesheetEntryDto = exports.TimesheetIdDto = exports.TimesheetEntryIdDto = exports.ReviewTimesheetDto = exports.GetTimesheetsDto = exports.GetTimesheetReportDto = exports.CreateTimesheetEntryDto = exports.todayInJohannesburg = exports.shiftDays = exports.leavePeriodFor = exports.isWithinPeriod = exports.countWorkingDays = exports.addOneYearMinusDay = exports.GetPublicHolidaysDto = exports.UpdateLeaveTypeDto = exports.UpdateLeaveRequestDto = exports.UpdateLeaveEntitlementDto = exports.ReviewLeaveRequestDto = exports.LeaveTypeIdDto = exports.LeaveRequestIdDto = exports.LeaveEntitlementIdDto = exports.GetLeaveTypesDto = exports.GetLeaveRequestsDto = exports.GetLeaveEntitlementsDto = exports.CreateLeaveTypeDto = exports.CreateLeaveRequestDto = exports.CreateLeaveEntitlementDto = exports.UpdateTaskDto = exports.TaskLinkIdDto = exports.TaskIdDto = exports.GetTasksDto = exports.CreateTaskDto = exports.AddTaskLinkDto = exports.UpdateProjectDto = exports.ProjectIdDto = exports.GetProjectsDto = exports.CreateProjectDto = exports.UpdateClientDto = exports.GetClientsDto = exports.CreateClientDto = exports.ClientIdDto = exports.SupportingFileIdDto = exports.GetSupportingFilesDto = exports.UserIdDto = exports.UpdateUserDto = exports.UpdateMyProfileDto = exports.GetUsersQueryDto = void 0;
12
12
  var client_1 = require("./client");
13
13
  Object.defineProperty(exports, "SupabaseClient", { enumerable: true, get: function () { return client_1.SupabaseClient; } });
14
14
  Object.defineProperty(exports, "createSupabaseClient", { enumerable: true, get: function () { return client_1.createSupabaseClient; } });
@@ -53,6 +53,11 @@ Object.defineProperty(exports, "AddStatusDto", { enumerable: true, get: function
53
53
  Object.defineProperty(exports, "GetStatusesDto", { enumerable: true, get: function () { return statuses_1.GetStatusesDto; } });
54
54
  Object.defineProperty(exports, "StatusIdDto", { enumerable: true, get: function () { return statuses_1.StatusIdDto; } });
55
55
  Object.defineProperty(exports, "UpdateStatusDto", { enumerable: true, get: function () { return statuses_1.UpdateStatusDto; } });
56
+ var labels_1 = require("./labels");
57
+ Object.defineProperty(exports, "AddLabelDto", { enumerable: true, get: function () { return labels_1.AddLabelDto; } });
58
+ Object.defineProperty(exports, "GetLabelsDto", { enumerable: true, get: function () { return labels_1.GetLabelsDto; } });
59
+ Object.defineProperty(exports, "LabelIdDto", { enumerable: true, get: function () { return labels_1.LabelIdDto; } });
60
+ Object.defineProperty(exports, "UpdateLabelDto", { enumerable: true, get: function () { return labels_1.UpdateLabelDto; } });
56
61
  var additional_details_1 = require("./additional-details");
57
62
  Object.defineProperty(exports, "AddAdditionalDetailsDto", { enumerable: true, get: function () { return additional_details_1.AddAdditionalDetailsDto; } });
58
63
  Object.defineProperty(exports, "AdditionalDetailsIdDto", { enumerable: true, get: function () { return additional_details_1.AdditionalDetailsIdDto; } });
@@ -95,9 +100,11 @@ Object.defineProperty(exports, "GetProjectsDto", { enumerable: true, get: functi
95
100
  Object.defineProperty(exports, "ProjectIdDto", { enumerable: true, get: function () { return projects_1.ProjectIdDto; } });
96
101
  Object.defineProperty(exports, "UpdateProjectDto", { enumerable: true, get: function () { return projects_1.UpdateProjectDto; } });
97
102
  var tasks_1 = require("./tasks");
103
+ Object.defineProperty(exports, "AddTaskLinkDto", { enumerable: true, get: function () { return tasks_1.AddTaskLinkDto; } });
98
104
  Object.defineProperty(exports, "CreateTaskDto", { enumerable: true, get: function () { return tasks_1.CreateTaskDto; } });
99
105
  Object.defineProperty(exports, "GetTasksDto", { enumerable: true, get: function () { return tasks_1.GetTasksDto; } });
100
106
  Object.defineProperty(exports, "TaskIdDto", { enumerable: true, get: function () { return tasks_1.TaskIdDto; } });
107
+ Object.defineProperty(exports, "TaskLinkIdDto", { enumerable: true, get: function () { return tasks_1.TaskLinkIdDto; } });
101
108
  Object.defineProperty(exports, "UpdateTaskDto", { enumerable: true, get: function () { return tasks_1.UpdateTaskDto; } });
102
109
  var leave_1 = require("./leave");
103
110
  Object.defineProperty(exports, "CreateLeaveEntitlementDto", { enumerable: true, get: function () { return leave_1.CreateLeaveEntitlementDto; } });
@@ -0,0 +1,19 @@
1
+ import type { AddLabelDto as AddLabelDtoType, GetLabelsDto as GetLabelsDtoType, LabelIdDto as LabelIdDtoType, UpdateLabelDto as UpdateLabelDtoType } from '@managemint-solutions/entities/labels/dto';
2
+ import { LabelEntityTypes } from '@managemint-solutions/entities/labels/enum';
3
+ export declare class LabelIdDto implements LabelIdDtoType {
4
+ readonly labelId: string;
5
+ }
6
+ export declare class GetLabelsDto implements GetLabelsDtoType {
7
+ readonly entity: LabelEntityTypes;
8
+ }
9
+ export declare class AddLabelDto implements AddLabelDtoType {
10
+ readonly name: string;
11
+ readonly colour: string;
12
+ readonly entity: LabelEntityTypes;
13
+ readonly description?: string | null;
14
+ }
15
+ export declare class UpdateLabelDto implements UpdateLabelDtoType {
16
+ readonly name: string;
17
+ readonly colour: string;
18
+ readonly description?: string | null;
19
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.UpdateLabelDto = exports.AddLabelDto = exports.GetLabelsDto = exports.LabelIdDto = void 0;
13
+ const class_validator_1 = require("class-validator");
14
+ const enum_1 = require("@managemint-solutions/entities/labels/enum");
15
+ const validators_1 = require("../validators");
16
+ // class-validator versions of the shared DTO types from @managemint-solutions/entities.
17
+ // Each class `implements` its entities type so the wire contract cannot drift.
18
+ class LabelIdDto {
19
+ labelId;
20
+ }
21
+ exports.LabelIdDto = LabelIdDto;
22
+ __decorate([
23
+ (0, class_validator_1.IsUUID)('7', { message: 'Invalid label ID format' }),
24
+ __metadata("design:type", String)
25
+ ], LabelIdDto.prototype, "labelId", void 0);
26
+ class GetLabelsDto {
27
+ entity;
28
+ }
29
+ exports.GetLabelsDto = GetLabelsDto;
30
+ __decorate([
31
+ (0, class_validator_1.IsEnum)(enum_1.LabelEntityTypes, { message: 'Invalid entity type' }),
32
+ __metadata("design:type", String)
33
+ ], GetLabelsDto.prototype, "entity", void 0);
34
+ class AddLabelDto {
35
+ name;
36
+ // Colour must be a valid hex code (e.g., #FFFFFF)
37
+ colour;
38
+ // `entity` is the wire name; the SDK maps it to the `entity_type` column.
39
+ entity;
40
+ // What the label is for; omitted or null means none.
41
+ description;
42
+ }
43
+ exports.AddLabelDto = AddLabelDto;
44
+ __decorate([
45
+ (0, class_validator_1.IsString)({ message: 'Invalid name' }),
46
+ (0, class_validator_1.MinLength)(1, { message: 'Name cannot be empty' }),
47
+ (0, class_validator_1.MaxLength)(50, { message: 'Name cannot exceed 50 characters' }),
48
+ __metadata("design:type", String)
49
+ ], AddLabelDto.prototype, "name", void 0);
50
+ __decorate([
51
+ (0, class_validator_1.IsString)({ message: 'Invalid colour format' }),
52
+ (0, class_validator_1.MinLength)(7, { message: 'Colour must be a valid hex code' }),
53
+ (0, class_validator_1.MaxLength)(7, { message: 'Colour must be a valid hex code' }),
54
+ (0, class_validator_1.Matches)(/^#[0-9A-Fa-f]{6}$/, { message: 'Colour must be a valid hex code' }),
55
+ __metadata("design:type", String)
56
+ ], AddLabelDto.prototype, "colour", void 0);
57
+ __decorate([
58
+ (0, class_validator_1.IsEnum)(enum_1.LabelEntityTypes, { message: 'Invalid entity type' }),
59
+ __metadata("design:type", String)
60
+ ], AddLabelDto.prototype, "entity", void 0);
61
+ __decorate([
62
+ (0, class_validator_1.IsOptional)(),
63
+ (0, validators_1.IsNullable)(),
64
+ (0, class_validator_1.IsString)({ message: 'Invalid description' }),
65
+ (0, class_validator_1.MaxLength)(255, { message: 'Description cannot exceed 255 characters' }),
66
+ __metadata("design:type", Object)
67
+ ], AddLabelDto.prototype, "description", void 0);
68
+ class UpdateLabelDto {
69
+ name;
70
+ // Colour must be a valid hex code (e.g., #FFFFFF)
71
+ colour;
72
+ // Omitted leaves the description as it is; null clears it.
73
+ description;
74
+ }
75
+ exports.UpdateLabelDto = UpdateLabelDto;
76
+ __decorate([
77
+ (0, class_validator_1.IsString)({ message: 'Invalid name' }),
78
+ (0, class_validator_1.MinLength)(1, { message: 'Name cannot be empty' }),
79
+ (0, class_validator_1.MaxLength)(50, { message: 'Name cannot exceed 50 characters' }),
80
+ __metadata("design:type", String)
81
+ ], UpdateLabelDto.prototype, "name", void 0);
82
+ __decorate([
83
+ (0, class_validator_1.IsString)({ message: 'Invalid colour format' }),
84
+ (0, class_validator_1.MinLength)(7, { message: 'Colour must be a valid hex code' }),
85
+ (0, class_validator_1.MaxLength)(7, { message: 'Colour must be a valid hex code' }),
86
+ (0, class_validator_1.Matches)(/^#[0-9A-Fa-f]{6}$/, { message: 'Colour must be a valid hex code' }),
87
+ __metadata("design:type", String)
88
+ ], UpdateLabelDto.prototype, "colour", void 0);
89
+ __decorate([
90
+ (0, class_validator_1.IsOptional)(),
91
+ (0, validators_1.IsNullable)(),
92
+ (0, class_validator_1.IsString)({ message: 'Invalid description' }),
93
+ (0, class_validator_1.MaxLength)(255, { message: 'Description cannot exceed 255 characters' }),
94
+ __metadata("design:type", Object)
95
+ ], UpdateLabelDto.prototype, "description", void 0);
@@ -0,0 +1,17 @@
1
+ import type { LabelEntity } from '@managemint-solutions/entities/labels';
2
+ import type { AddLabelDto, UpdateLabelDto } from '@managemint-solutions/entities/labels/dto';
3
+ import { LabelEntityTypes } from '@managemint-solutions/entities/labels/enum';
4
+ import type { AuthContext } from '../auth';
5
+ import { type AuditResource } from '../audit';
6
+ import type { TypedSupabaseClient } from '../client';
7
+ export * from './dto';
8
+ export declare class LabelsResource {
9
+ private readonly supabase;
10
+ private readonly auth;
11
+ private readonly table;
12
+ constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
13
+ list(entity: LabelEntityTypes): Promise<LabelEntity[]>;
14
+ create(input: AddLabelDto): Promise<LabelEntity>;
15
+ update(labelId: string, input: UpdateLabelDto): Promise<LabelEntity>;
16
+ remove(labelId: string): Promise<void>;
17
+ }
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.LabelsResource = void 0;
18
+ const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
19
+ const audit_1 = require("../audit");
20
+ const errors_1 = require("../errors");
21
+ __exportStar(require("./dto"), exports);
22
+ // The public contract is the shared LabelEntity from @managemint-solutions/entities; the
23
+ // `as LabelEntity` casts declare the shape the select string below produces (the columns are
24
+ // defined in the api's supabase/migrations).
25
+ const LABEL_SELECT = 'mms_id, name, colour, description';
26
+ // The audited write returns the whole row; the wire shape is the LABEL_SELECT columns.
27
+ const toLabel = (row) => ({
28
+ mms_id: row.mms_id,
29
+ name: row.name,
30
+ colour: row.colour,
31
+ description: row.description,
32
+ });
33
+ // The unique index is (organization, entity type, lower(name)), so a 23505 from a label write can
34
+ // only mean the name is taken. Postgres names the index; the caller gets a sentence instead.
35
+ const refuseDuplicateName = (error) => {
36
+ if (error instanceof errors_1.SupabaseClientError && error.code === '23505') {
37
+ throw new errors_1.SupabaseClientError({ status: 409, error: error.error, message: 'A label with that name already exists', code: error.code }, { cause: error });
38
+ }
39
+ throw error;
40
+ };
41
+ class LabelsResource {
42
+ supabase;
43
+ auth;
44
+ table;
45
+ constructor(supabase, auth, audit) {
46
+ this.supabase = supabase;
47
+ this.auth = auth;
48
+ this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
49
+ table: 'labels',
50
+ entityType: enum_1.AuditTrailEntityType.LABEL,
51
+ });
52
+ }
53
+ async list(entity) {
54
+ const { data, error } = await this.supabase
55
+ .from('labels')
56
+ .select(LABEL_SELECT)
57
+ .eq('organization_id', this.auth.organization_id)
58
+ .eq('entity_type', entity);
59
+ if (error)
60
+ throw (0, errors_1.mapPostgrestError)(error);
61
+ return data;
62
+ }
63
+ async create(input) {
64
+ const row = await this.table.insert({
65
+ name: input.name,
66
+ colour: input.colour,
67
+ entity_type: input.entity,
68
+ description: input.description ?? null,
69
+ organization_id: this.auth.organization_id,
70
+ created_by: this.auth.mms_id,
71
+ }).catch(refuseDuplicateName);
72
+ return toLabel(row);
73
+ }
74
+ async update(labelId, input) {
75
+ const row = await this.table.update(labelId, {
76
+ name: input.name,
77
+ colour: input.colour,
78
+ ...(input.description !== undefined && { description: input.description }),
79
+ updated_at: new Date().toISOString(),
80
+ last_updated_by: this.auth.mms_id,
81
+ }).catch(refuseDuplicateName);
82
+ return toLabel(row);
83
+ }
84
+ async remove(labelId) {
85
+ await this.table.remove(labelId);
86
+ }
87
+ }
88
+ exports.LabelsResource = LabelsResource;
@@ -1,8 +1,17 @@
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
  }
5
- export declare class GetTasksDto implements Omit<GetTasksDtoType, 'status_id_in' | 'status_id_not_in' | 'priority_in'> {
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
+ }
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;
8
17
  readonly page_size: number;
@@ -17,6 +26,7 @@ export declare class GetTasksDto implements Omit<GetTasksDtoType, 'status_id_in'
17
26
  readonly end_date_before?: string | null;
18
27
  readonly status_id_in?: string[] | null;
19
28
  readonly status_id_not_in?: string[] | null;
29
+ readonly label_id_in?: string[] | null;
20
30
  readonly completed?: boolean | null;
21
31
  readonly priority_in?: number[] | null;
22
32
  readonly archive: boolean;
@@ -31,6 +41,7 @@ export declare class CreateTaskDto implements CreateTaskDtoType {
31
41
  readonly assigned_to_id?: string | null;
32
42
  readonly project_id?: string | null;
33
43
  readonly client_id?: string | null;
44
+ readonly label_ids?: string[] | null;
34
45
  }
35
46
  export declare class UpdateTaskDto implements UpdateTaskDtoType {
36
47
  readonly name: string;
@@ -42,4 +53,5 @@ export declare class UpdateTaskDto implements UpdateTaskDtoType {
42
53
  readonly assigned_to_id?: string | null;
43
54
  readonly project_id?: string | null;
44
55
  readonly client_id?: string | null;
56
+ readonly label_ids?: string[] | null;
45
57
  }
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,10 +26,38 @@ __decorate([
25
26
  ,
26
27
  __metadata("design:type", String)
27
28
  ], TaskIdDto.prototype, "taskId", void 0);
28
- // `status_id_in`, `status_id_not_in` and `priority_in` are omitted from the implemented type on
29
- // purpose: the entities type carries the CSV wire values, while this class carries the
30
- // `@Transform`-parsed arrays the query builder feeds to `.in('status_id', ...)` /
31
- // `.in('priority', ...)` and to the `status_id.is.null,status_id.not.in.(...)` exclusion.
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);
56
+ // `status_id_in`, `status_id_not_in`, `label_id_in` and `priority_in` are omitted from the
57
+ // implemented type on purpose: the entities type carries the CSV wire values, while this class
58
+ // carries the `@Transform`-parsed arrays the query builder feeds to `.in('status_id', ...)` /
59
+ // `.in('task_labels.label_id', ...)` / `.in('priority', ...)` and to the
60
+ // `status_id.is.null,status_id.not.in.(...)` exclusion.
32
61
  class GetTasksDto {
33
62
  search;
34
63
  page = 1;
@@ -44,6 +73,8 @@ class GetTasksDto {
44
73
  end_date_before;
45
74
  status_id_in;
46
75
  status_id_not_in;
76
+ // A task matches when it carries any one of these labels.
77
+ label_id_in;
47
78
  // Tri-state: true = only completed rows, false = only rows that are not completed, omitted = no
48
79
  // completion filter. The api resolves it to status ids before it reaches `list`.
49
80
  completed;
@@ -147,6 +178,13 @@ __decorate([
147
178
  (0, class_validator_1.IsUUID)('7', { each: true, message: 'Invalid status ID format' }),
148
179
  __metadata("design:type", Object)
149
180
  ], GetTasksDto.prototype, "status_id_not_in", void 0);
181
+ __decorate([
182
+ (0, class_validator_1.IsOptional)(),
183
+ (0, validators_1.IsNullable)(),
184
+ (0, class_transformer_1.Transform)(({ value }) => value ? value.split(',').map((id) => id.trim()) : null, { toClassOnly: true }),
185
+ (0, class_validator_1.IsUUID)('7', { each: true, message: 'Invalid label ID format' }),
186
+ __metadata("design:type", Object)
187
+ ], GetTasksDto.prototype, "label_id_in", void 0);
150
188
  __decorate([
151
189
  (0, class_validator_1.IsOptional)(),
152
190
  (0, validators_1.IsNullable)(),
@@ -178,6 +216,8 @@ class CreateTaskDto {
178
216
  assigned_to_id;
179
217
  project_id;
180
218
  client_id;
219
+ // The labels to pin to the new task; omitted means none.
220
+ label_ids;
181
221
  }
182
222
  exports.CreateTaskDto = CreateTaskDto;
183
223
  __decorate([
@@ -239,6 +279,13 @@ __decorate([
239
279
  (0, class_validator_1.IsUUID)('7', { message: 'Invalid client ID format' }),
240
280
  __metadata("design:type", Object)
241
281
  ], CreateTaskDto.prototype, "client_id", void 0);
282
+ __decorate([
283
+ (0, class_validator_1.IsOptional)(),
284
+ (0, class_validator_1.IsArray)({ message: 'Label IDs must be an array' }),
285
+ (0, class_validator_1.ArrayUnique)({ message: 'Label IDs must not repeat' }),
286
+ (0, class_validator_1.IsUUID)('7', { each: true, message: 'Invalid label ID format' }),
287
+ __metadata("design:type", Object)
288
+ ], CreateTaskDto.prototype, "label_ids", void 0);
242
289
  class UpdateTaskDto {
243
290
  name;
244
291
  description;
@@ -249,6 +296,9 @@ class UpdateTaskDto {
249
296
  assigned_to_id;
250
297
  project_id;
251
298
  client_id;
299
+ // The full set of labels after the update: an array replaces the set (empty clears it);
300
+ // omitted or null leaves the labels as they are.
301
+ label_ids;
252
302
  }
253
303
  exports.UpdateTaskDto = UpdateTaskDto;
254
304
  __decorate([
@@ -312,3 +362,10 @@ __decorate([
312
362
  (0, class_validator_1.IsUUID)('7', { message: 'Invalid client ID format' }),
313
363
  __metadata("design:type", Object)
314
364
  ], UpdateTaskDto.prototype, "client_id", void 0);
365
+ __decorate([
366
+ (0, class_validator_1.IsOptional)(),
367
+ (0, class_validator_1.IsArray)({ message: 'Label IDs must be an array' }),
368
+ (0, class_validator_1.ArrayUnique)({ message: 'Label IDs must not repeat' }),
369
+ (0, class_validator_1.IsUUID)('7', { each: true, message: 'Invalid label ID format' }),
370
+ __metadata("design:type", Object)
371
+ ], UpdateTaskDto.prototype, "label_ids", void 0);
@@ -3,11 +3,12 @@ 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;
10
10
  private readonly auth;
11
+ private readonly audit;
11
12
  private readonly table;
12
13
  constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
13
14
  list(query: GetTasksDto): Promise<MmsList<TaskSummary>>;
@@ -19,4 +20,34 @@ export declare class TasksResource {
19
20
  restore(taskId: string): Promise<TaskEntity>;
20
21
  /** Soft delete - the row stays, flagged `deleted`, and `restore()` brings it back. */
21
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>;
37
+ /**
38
+ * Refuses, before anything is written, a label id that is not one of this organization's task
39
+ * labels - one deleted while the form was open, or another organization's. The `task_labels`
40
+ * trigger enforces the same rule, but only once the task row has already been saved, so
41
+ * without this a create would answer an error for a task it had in fact created.
42
+ */
43
+ private checkLabels;
44
+ /**
45
+ * Makes `labelIds` the task's whole label set. The join rows carry no audit of their own: the
46
+ * change is one UPDATE entry on the task, `labels: { old: [names], new: [names] }`, recorded
47
+ * only when the set actually moved - on a new task that entry is the record of the initial
48
+ * pinning. Only the difference is written, the new labels inserted before the dropped ones are
49
+ * deleted, so should the trigger still refuse one (a label deleted after `checkLabels`) the
50
+ * task's existing labels are untouched, and a label kept across an edit keeps its place.
51
+ */
52
+ private setLabels;
22
53
  }
@@ -15,7 +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/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");
19
21
  const audit_1 = require("../audit");
20
22
  const errors_1 = require("../errors");
21
23
  const transforms_1 = require("../transforms");
@@ -38,10 +40,25 @@ const TASK_LIST_SELECT = `
38
40
  is_triage
39
41
  ),
40
42
  priority,
43
+ labels:labels!task_labels (
44
+ mms_id,
45
+ name,
46
+ colour,
47
+ description
48
+ ),
41
49
  assigned_to:user_profiles!assigned_to_id (*),
42
50
  project:project_profiles!project_id (*),
43
51
  client:client_profiles!client_id (*)
44
52
  `;
53
+ // A linked task as the detail read shows it. `deleted` is read only so a soft-deleted task can be
54
+ // left out of the links; it is not part of LinkedTask.
55
+ const LINKED_TASK_FIELDS = `
56
+ mms_id,
57
+ task_number:task_id,
58
+ name,
59
+ priority,
60
+ deleted,
61
+ status:statuses!status_id ( mms_id, colour, label, is_complete, is_fail, is_triage )`;
45
62
  const TASK_DETAIL_SELECT = `
46
63
  mms_id,
47
64
  created_at,
@@ -62,6 +79,12 @@ const TASK_DETAIL_SELECT = `
62
79
  is_triage
63
80
  ),
64
81
  priority,
82
+ labels:labels!task_labels (
83
+ mms_id,
84
+ name,
85
+ colour,
86
+ description
87
+ ),
65
88
  assigned_to:user_profiles!assigned_to_id (*),
66
89
  project:project_profiles!project_id (*),
67
90
  client:client_profiles!client_id (*),
@@ -74,18 +97,40 @@ const TASK_DETAIL_SELECT = `
74
97
  deleted_at,
75
98
  deleted_by:user_profiles!deleted_by (*),
76
99
  restored_at,
77
- restored_by:user_profiles!restored_by (*)
100
+ restored_by:user_profiles!restored_by (*),
101
+ links_out:task_links!task_id (
102
+ link_type,
103
+ task:tasks!linked_task_id ( ${LINKED_TASK_FIELDS} )
104
+ ),
105
+ links_in:task_links!linked_task_id (
106
+ link_type,
107
+ task:tasks!task_id ( ${LINKED_TASK_FIELDS} )
108
+ )
78
109
  `;
110
+ // Added to the list select only when `label_id_in` is set: an inner join on the join table is how
111
+ // PostgREST filters a task by the labels it carries.
112
+ const LABEL_FILTER_SELECT = 'task_labels!inner ( label_id )';
113
+ // A `task_labels` row as `setLabels` reads and writes it: the label id, and the name the audit
114
+ // entry records.
115
+ const PINNED_LABEL_SELECT = 'label_id, label:labels ( name )';
116
+ const linkedTasks = (rows, linkType) => rows
117
+ .filter((row) => row.link_type === linkType && row.task && !row.task.deleted)
118
+ .map((row) => {
119
+ const { deleted: _deleted, ...task } = row.task;
120
+ return task;
121
+ });
79
122
  class TasksResource {
80
123
  supabase;
81
124
  auth;
125
+ audit;
82
126
  table;
83
127
  constructor(supabase, auth, audit) {
84
128
  this.supabase = supabase;
85
129
  this.auth = auth;
130
+ this.audit = audit;
86
131
  this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
87
132
  table: 'tasks',
88
- entityType: enum_1.AuditTrailEntityType.TASKS,
133
+ entityType: enum_3.AuditTrailEntityType.TASKS,
89
134
  });
90
135
  }
91
136
  async list(query) {
@@ -93,11 +138,14 @@ class TasksResource {
93
138
  const pageSize = query.page_size;
94
139
  const offset = (page - 1) * pageSize;
95
140
  const trimmedSearch = query.search?.trim();
141
+ const labelIdIn = query.label_id_in && query.label_id_in.length > 0 ? query.label_id_in : null;
96
142
  // `order`/`range` return `this`, so the builder stays a filter builder and the conditional
97
143
  // filters below can be reassigned onto it without a cast.
98
144
  let tasksQuery = this.supabase
99
145
  .from('tasks')
100
- .select(TASK_LIST_SELECT, { count: 'estimated' })
146
+ .select(labelIdIn ? `${TASK_LIST_SELECT}, ${LABEL_FILTER_SELECT}` : TASK_LIST_SELECT, {
147
+ count: 'estimated',
148
+ })
101
149
  .eq('organization_id', this.auth.organization_id)
102
150
  .eq('archived', query.archive)
103
151
  .eq('deleted', false)
@@ -152,10 +200,19 @@ class TasksResource {
152
200
  if (query.priority_in && query.priority_in.length > 0) {
153
201
  tasksQuery = tasksQuery.in('priority', query.priority_in);
154
202
  }
203
+ // A task matches when it carries any of the labels. The `!inner` join on `task_labels` only
204
+ // narrows the rows - the `labels` embed is a separate join, so a matched task still shows
205
+ // every label it carries. The join's rows are not part of TaskSummary and are dropped below.
206
+ if (labelIdIn) {
207
+ tasksQuery = tasksQuery.in('task_labels.label_id', labelIdIn);
208
+ }
155
209
  const { data, error, count } = await tasksQuery;
156
210
  if (error)
157
211
  throw (0, errors_1.mapPostgrestError)(error);
158
- return (0, transforms_1.toMmsList)(data, page, pageSize, count);
212
+ const rows = labelIdIn
213
+ ? data.map(({ task_labels: _filterRows, ...task }) => task)
214
+ : data;
215
+ return (0, transforms_1.toMmsList)(rows, page, pageSize, count);
159
216
  }
160
217
  async getById(taskId) {
161
218
  const { data, error } = await this.supabase
@@ -163,45 +220,262 @@ class TasksResource {
163
220
  .select(TASK_DETAIL_SELECT)
164
221
  .eq('mms_id', taskId)
165
222
  .eq('organization_id', this.auth.organization_id)
223
+ .order('created_at', { referencedTable: 'links_out' })
224
+ .order('created_at', { referencedTable: 'links_in' })
166
225
  .single();
167
226
  if (error)
168
227
  throw (0, errors_1.mapPostgrestError)(error);
169
- return data;
228
+ // Related is stored once, so it is read from both directions; a child link is the parent
229
+ // from the child's side and a subtask from the parent's.
230
+ const { links_out: linksOut, links_in: linksIn, ...task } = data;
231
+ return {
232
+ ...task,
233
+ parent: linkedTasks(linksIn, enum_1.TaskLinkType.CHILD)[0] ?? null,
234
+ subtasks: linkedTasks(linksOut, enum_1.TaskLinkType.CHILD),
235
+ related: [...linkedTasks(linksOut, enum_1.TaskLinkType.RELATED), ...linkedTasks(linksIn, enum_1.TaskLinkType.RELATED)],
236
+ };
170
237
  }
171
238
  async create(input) {
239
+ // `label_ids` is not a tasks column: the labels are join rows, written after the task.
240
+ const { label_ids: labelIds, ...fields } = input;
241
+ if (labelIds) {
242
+ await this.checkLabels(labelIds);
243
+ }
172
244
  // `task_id` (the per-organization task number) is left out: the table's `set_task_id`
173
245
  // BEFORE INSERT trigger fills it in when it arrives null.
174
246
  const row = await this.table.insert({
175
- ...input,
247
+ ...fields,
176
248
  organization_id: this.auth.organization_id,
177
249
  created_by: this.auth.mms_id,
178
250
  created_at: new Date().toISOString(),
179
251
  });
252
+ if (labelIds) {
253
+ await this.setLabels(row.mms_id, labelIds);
254
+ }
180
255
  return this.getById(row.mms_id);
181
256
  }
182
257
  async update(taskId, input) {
258
+ // `label_ids` is not a tasks column; omitted or null leaves the labels as they are.
259
+ const { label_ids: labelIds, ...fields } = input;
260
+ if (labelIds) {
261
+ await this.checkLabels(labelIds);
262
+ }
183
263
  await this.table.update(taskId, {
184
- ...input,
264
+ ...fields,
185
265
  last_updated_by: this.auth.mms_id,
186
266
  updated_at: new Date().toISOString(),
187
267
  });
268
+ if (labelIds) {
269
+ await this.setLabels(taskId, labelIds);
270
+ }
188
271
  return this.getById(taskId);
189
272
  }
190
273
  async archive(taskId) {
191
- await this.table.transition(taskId, enum_1.AuditTrailAction.ARCHIVE, (0, transforms_1.archivedPatch)(this.auth.mms_id));
274
+ await this.table.transition(taskId, enum_3.AuditTrailAction.ARCHIVE, (0, transforms_1.archivedPatch)(this.auth.mms_id));
192
275
  return this.getById(taskId);
193
276
  }
194
277
  async unarchive(taskId) {
195
- await this.table.transition(taskId, enum_1.AuditTrailAction.UNARCHIVE, (0, transforms_1.unarchivedPatch)(this.auth.mms_id));
278
+ await this.table.transition(taskId, enum_3.AuditTrailAction.UNARCHIVE, (0, transforms_1.unarchivedPatch)(this.auth.mms_id));
196
279
  return this.getById(taskId);
197
280
  }
198
281
  async restore(taskId) {
199
- await this.table.transition(taskId, enum_1.AuditTrailAction.RESTORE, (0, transforms_1.restoredPatch)(this.auth.mms_id));
282
+ await this.table.transition(taskId, enum_3.AuditTrailAction.RESTORE, (0, transforms_1.restoredPatch)(this.auth.mms_id));
200
283
  return this.getById(taskId);
201
284
  }
202
285
  /** Soft delete - the row stays, flagged `deleted`, and `restore()` brings it back. */
203
286
  async remove(taskId) {
204
- await this.table.transition(taskId, enum_1.AuditTrailAction.DELETE, (0, transforms_1.deletedPatch)(this.auth.mms_id));
287
+ await this.table.transition(taskId, enum_3.AuditTrailAction.DELETE, (0, transforms_1.deletedPatch)(this.auth.mms_id));
288
+ }
289
+ /**
290
+ * Links `input.linked_task_id` to the task as a subtask or as related. The rules live here,
291
+ * checked before the insert: not the task itself, both tasks this organization's and not
292
+ * deleted, and no loop of subtasks. The two a concurrent write could still break - one link
293
+ * per pair, one parent per task - are unique indexes, and their violations are turned into
294
+ * sentences. The link has no audit of its own: it is one UPDATE entry on the task it was
295
+ * made from.
296
+ */
297
+ async linkTask(taskId, input) {
298
+ const refuse = (status, message, cause) => new errors_1.SupabaseClientError({ status, error: 'Database Error', message }, { cause });
299
+ if (input.linked_task_id === taskId) {
300
+ throw refuse(400, 'A task cannot be linked to itself');
301
+ }
302
+ const { data: tasks, error: tasksError } = await this.supabase
303
+ .from('tasks')
304
+ .select('mms_id, task_number:task_id')
305
+ .eq('organization_id', this.auth.organization_id)
306
+ .eq('deleted', false)
307
+ .in('mms_id', [taskId, input.linked_task_id]);
308
+ if (tasksError)
309
+ throw (0, errors_1.mapPostgrestError)(tasksError);
310
+ const linked = tasks.find((task) => task.mms_id === input.linked_task_id);
311
+ if (tasks.length !== 2 || !linked) {
312
+ throw refuse(400, 'Invalid task for this organization');
313
+ }
314
+ if (input.link_type === enum_1.TaskLinkType.CHILD) {
315
+ // Walk up from the new parent: were the new subtask among its ancestors, the link would
316
+ // close a loop. The chain is a few links deep. `seen` stops the walk should two concurrent
317
+ // links ever have made a loop after all, rather than reading forever.
318
+ const seen = new Set();
319
+ let ancestor = taskId;
320
+ while (ancestor && !seen.has(ancestor)) {
321
+ const child = ancestor;
322
+ seen.add(child);
323
+ const { data: parentLink, error: parentError } = await this.supabase
324
+ .from('task_links')
325
+ .select('task_id')
326
+ .eq('organization_id', this.auth.organization_id)
327
+ .eq('linked_task_id', child)
328
+ .eq('link_type', enum_1.TaskLinkType.CHILD)
329
+ .maybeSingle();
330
+ if (parentError)
331
+ throw (0, errors_1.mapPostgrestError)(parentError);
332
+ ancestor = parentLink?.task_id ?? null;
333
+ if (ancestor === input.linked_task_id) {
334
+ throw refuse(400, 'A task cannot be a subtask of its own subtask');
335
+ }
336
+ }
337
+ }
338
+ const { error } = await this.supabase.from('task_links').insert({
339
+ task_id: taskId,
340
+ linked_task_id: input.linked_task_id,
341
+ link_type: input.link_type,
342
+ organization_id: this.auth.organization_id,
343
+ created_by: this.auth.mms_id,
344
+ });
345
+ if (error) {
346
+ // Postgres names the violated index: the primary key and the pair index both mean the two
347
+ // tasks are linked already.
348
+ if (error.code === '23505' && error.message.includes('uq_task_links_one_parent')) {
349
+ throw refuse(409, 'That task already has a parent', error);
350
+ }
351
+ if (error.code === '23505') {
352
+ throw refuse(409, 'These tasks are already linked', error);
353
+ }
354
+ throw (0, errors_1.mapPostgrestError)(error);
355
+ }
356
+ const role = input.link_type === enum_1.TaskLinkType.CHILD ? 'subtask' : 'related';
357
+ await this.audit.record({
358
+ action: enum_3.AuditTrailAction.UPDATE,
359
+ entityType: enum_3.AuditTrailEntityType.TASKS,
360
+ entityId: taskId,
361
+ changes: { linked_task: { old: null, new: `TASK-${linked.task_number} (${role})` } },
362
+ });
363
+ return this.getById(taskId);
364
+ }
365
+ /**
366
+ * Removes the link between the two tasks, whichever of them it was made from - a pair carries
367
+ * one link at most. Unlinked from the subtask's side, the entry names the other task as parent.
368
+ */
369
+ async unlinkTask(taskId, linkedTaskId) {
370
+ // Both ids are `@IsUUID('7')`-validated by TaskLinkIdDto, so interpolating them is safe.
371
+ const { data, error } = await this.supabase
372
+ .from('task_links')
373
+ .delete()
374
+ .eq('organization_id', this.auth.organization_id)
375
+ .or(`and(task_id.eq.${taskId},linked_task_id.eq.${linkedTaskId}),and(task_id.eq.${linkedTaskId},linked_task_id.eq.${taskId})`)
376
+ .select('task_id, link_type, from:tasks!task_id ( task_number:task_id ), to:tasks!linked_task_id ( task_number:task_id )');
377
+ if (error)
378
+ throw (0, errors_1.mapPostgrestError)(error);
379
+ const [row] = data;
380
+ if (!row) {
381
+ throw new errors_1.SupabaseClientError({ status: 404, error: 'Database Error', message: 'Link not found' });
382
+ }
383
+ const madeHere = row.task_id === taskId;
384
+ const role = row.link_type === enum_1.TaskLinkType.RELATED ? 'related' : madeHere ? 'subtask' : 'parent';
385
+ await this.audit.record({
386
+ action: enum_3.AuditTrailAction.UPDATE,
387
+ entityType: enum_3.AuditTrailEntityType.TASKS,
388
+ entityId: taskId,
389
+ changes: {
390
+ linked_task: { old: `TASK-${(madeHere ? row.to : row.from).task_number} (${role})`, new: null },
391
+ },
392
+ });
393
+ return this.getById(taskId);
394
+ }
395
+ /**
396
+ * Refuses, before anything is written, a label id that is not one of this organization's task
397
+ * labels - one deleted while the form was open, or another organization's. The `task_labels`
398
+ * trigger enforces the same rule, but only once the task row has already been saved, so
399
+ * without this a create would answer an error for a task it had in fact created.
400
+ */
401
+ async checkLabels(labelIds) {
402
+ if (labelIds.length === 0)
403
+ return;
404
+ const { count, error } = await this.supabase
405
+ .from('labels')
406
+ .select('mms_id', { count: 'exact', head: true })
407
+ .eq('organization_id', this.auth.organization_id)
408
+ .eq('entity_type', enum_2.LabelEntityTypes.TASK)
409
+ .in('mms_id', labelIds);
410
+ if (error)
411
+ throw (0, errors_1.mapPostgrestError)(error);
412
+ if (count !== labelIds.length) {
413
+ throw new errors_1.SupabaseClientError({
414
+ status: 400,
415
+ error: 'Database Error',
416
+ message: 'Invalid label_id for this organization',
417
+ });
418
+ }
419
+ }
420
+ /**
421
+ * Makes `labelIds` the task's whole label set. The join rows carry no audit of their own: the
422
+ * change is one UPDATE entry on the task, `labels: { old: [names], new: [names] }`, recorded
423
+ * only when the set actually moved - on a new task that entry is the record of the initial
424
+ * pinning. Only the difference is written, the new labels inserted before the dropped ones are
425
+ * deleted, so should the trigger still refuse one (a label deleted after `checkLabels`) the
426
+ * task's existing labels are untouched, and a label kept across an edit keeps its place.
427
+ */
428
+ async setLabels(taskId, labelIds) {
429
+ const { data: current, error: readError } = await this.supabase
430
+ .from('task_labels')
431
+ .select(PINNED_LABEL_SELECT)
432
+ .eq('task_id', taskId)
433
+ .eq('organization_id', this.auth.organization_id)
434
+ .order('created_at');
435
+ if (readError)
436
+ throw (0, errors_1.mapPostgrestError)(readError);
437
+ const pinned = current;
438
+ const added = labelIds.filter((labelId) => !pinned.some((row) => row.label_id === labelId));
439
+ const kept = pinned.filter((row) => labelIds.includes(row.label_id));
440
+ const dropped = pinned.filter((row) => !labelIds.includes(row.label_id));
441
+ if (added.length === 0 && dropped.length === 0)
442
+ return;
443
+ let addedRows = [];
444
+ if (added.length > 0) {
445
+ const { data, error } = await this.supabase
446
+ .from('task_labels')
447
+ .insert(added.map((labelId) => ({
448
+ task_id: taskId,
449
+ label_id: labelId,
450
+ organization_id: this.auth.organization_id,
451
+ created_by: this.auth.mms_id,
452
+ })))
453
+ .select(PINNED_LABEL_SELECT);
454
+ if (error)
455
+ throw (0, errors_1.mapPostgrestError)(error);
456
+ addedRows = data;
457
+ }
458
+ if (dropped.length > 0) {
459
+ const { error } = await this.supabase
460
+ .from('task_labels')
461
+ .delete()
462
+ .eq('task_id', taskId)
463
+ .eq('organization_id', this.auth.organization_id)
464
+ .in('label_id', dropped.map((row) => row.label_id));
465
+ if (error)
466
+ throw (0, errors_1.mapPostgrestError)(error);
467
+ }
468
+ await this.audit.record({
469
+ action: enum_3.AuditTrailAction.UPDATE,
470
+ entityType: enum_3.AuditTrailEntityType.TASKS,
471
+ entityId: taskId,
472
+ changes: {
473
+ labels: {
474
+ old: pinned.map((row) => row.label.name),
475
+ new: [...kept, ...addedRows].map((row) => row.label.name),
476
+ },
477
+ },
478
+ });
205
479
  }
206
480
  }
207
481
  exports.TasksResource = TasksResource;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@managemint-solutions/sdk",
3
- "version": "0.41.0",
3
+ "version": "0.43.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.24.0"
54
+ "@managemint-solutions/entities": "^1.26.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@nestjs/common": "^11.0.0",