@managemint-solutions/sdk 0.45.0 → 0.50.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.
@@ -16,7 +16,7 @@ const LEAVE_REQUEST_SELECT = `
16
16
  leave_type:leave_types!leave_type_id (
17
17
  mms_id,
18
18
  name,
19
- requires_document
19
+ document_after_days
20
20
  ),
21
21
  start_date,
22
22
  end_date,
@@ -30,6 +30,19 @@ const LEAVE_REQUEST_SELECT = `
30
30
  cancelled_by:user_profiles!cancelled_by (*),
31
31
  cancelled_at
32
32
  `;
33
+ const LEAVE_CALENDAR_SELECT = `
34
+ mms_id,
35
+ user:user_profiles!user_id (*),
36
+ created_by:user_profiles!created_by (*),
37
+ leave_type:leave_types!leave_type_id (
38
+ mms_id,
39
+ name
40
+ ),
41
+ start_date,
42
+ end_date,
43
+ is_half_day,
44
+ status
45
+ `;
33
46
  /** The statuses that hold days: a pending request reserves them, an approved one uses them. */
34
47
  const LIVE_STATUSES = [enum_2.LeaveRequestStatus.PENDING, enum_2.LeaveRequestStatus.APPROVED];
35
48
  class LeaveRequestsResource {
@@ -110,28 +123,27 @@ class LeaveRequestsResource {
110
123
  throw (0, errors_1.mapPostgrestError)(error);
111
124
  return (count ?? 0) > 0;
112
125
  }
113
- /**
114
- * The days of `userId`'s requests of `leaveTypeId` in `statuses` that start inside `period` -
115
- * what an entitlement's balance is checked against. `excludeId` leaves out the request being
116
- * edited or approved so it is not counted against itself.
117
- */
118
- async sumDays(userId, leaveTypeId, period, statuses, excludeId) {
119
- let daysQuery = this.supabase
120
- .from('leave_requests')
121
- .select('days')
122
- .eq('organization_id', this.auth.organization_id)
123
- .eq('user_id', userId)
124
- .eq('leave_type_id', leaveTypeId)
125
- .in('status', statuses)
126
- .gte('start_date', period.period_start)
127
- .lte('start_date', period.period_end);
128
- if (excludeId) {
129
- daysQuery = daysQuery.neq('mms_id', excludeId);
130
- }
131
- const { data, error } = await daysQuery;
132
- if (error)
133
- throw (0, errors_1.mapPostgrestError)(error);
134
- return (data ?? []).reduce((total, row) => total + Number(row.days), 0);
126
+ /** Live requests touching `date_from`..`date_to`, for everyone in the organization - the leave calendar. */
127
+ async calendar(query) {
128
+ return (0, transforms_1.allRows)((from, to) => {
129
+ let calendarQuery = this.supabase
130
+ .from('leave_requests')
131
+ .select(LEAVE_CALENDAR_SELECT)
132
+ .eq('organization_id', this.auth.organization_id)
133
+ .in('status', LIVE_STATUSES)
134
+ .lte('start_date', query.date_to)
135
+ .gte('end_date', query.date_from)
136
+ .order('start_date')
137
+ .order('mms_id')
138
+ .range(from, to);
139
+ if (query.user_id) {
140
+ calendarQuery = calendarQuery.eq('user_id', query.user_id);
141
+ }
142
+ if (query.leave_type_id) {
143
+ calendarQuery = calendarQuery.eq('leave_type_id', query.leave_type_id);
144
+ }
145
+ return calendarQuery;
146
+ });
135
147
  }
136
148
  async create(input) {
137
149
  const row = await this.table.insert({
@@ -169,10 +181,13 @@ class LeaveRequestsResource {
169
181
  });
170
182
  return row ? this.getById(leaveRequestId) : null;
171
183
  }
172
- /** Cancels a request while it is still pending; null when it no longer is - a decided request is final. */
173
- async cancel(leaveRequestId, cancelledBy) {
184
+ /**
185
+ * Cancels a request that is still in `expected` - the status the api read and decided on.
186
+ * Null when it has moved on since (reviewed, or cancelled by someone else).
187
+ */
188
+ async cancel(leaveRequestId, cancelledBy, expected) {
174
189
  const now = new Date().toISOString();
175
- const row = await this.table.updateWhere(leaveRequestId, (query) => query.eq('status', enum_2.LeaveRequestStatus.PENDING), {
190
+ const row = await this.table.updateWhere(leaveRequestId, (query) => query.eq('status', expected), {
176
191
  status: enum_2.LeaveRequestStatus.CANCELLED,
177
192
  cancelled_by: cancelledBy,
178
193
  cancelled_at: now,
@@ -18,9 +18,26 @@ const LEAVE_TYPE_SELECT = `
18
18
  default_days,
19
19
  is_paid,
20
20
  requires_approval,
21
- requires_document,
21
+ document_after_days,
22
+ cycle_years,
23
+ grant_method,
24
+ max_carry_over_days,
25
+ carry_over_expiry_months,
26
+ min_service_months,
27
+ min_days_per_week,
28
+ initial_months,
29
+ initial_days_per_month,
30
+ paid_on_exit,
22
31
  active
23
32
  `;
33
+ /** Postgres hands `numeric` back as a string; the entity promises numbers. */
34
+ const toLeaveType = (row) => ({
35
+ ...row,
36
+ default_days: Number(row.default_days),
37
+ max_carry_over_days: Number(row.max_carry_over_days),
38
+ document_after_days: row.document_after_days === null ? null : Number(row.document_after_days),
39
+ initial_days_per_month: row.initial_days_per_month === null ? null : Number(row.initial_days_per_month),
40
+ });
24
41
  class LeaveTypesResource {
25
42
  supabase;
26
43
  auth;
@@ -46,7 +63,7 @@ class LeaveTypesResource {
46
63
  const { data, error } = await typesQuery;
47
64
  if (error)
48
65
  throw (0, errors_1.mapPostgrestError)(error);
49
- return data;
66
+ return (data ?? []).map(toLeaveType);
50
67
  }
51
68
  async getById(leaveTypeId) {
52
69
  const { data, error } = await this.supabase
@@ -57,7 +74,7 @@ class LeaveTypesResource {
57
74
  .single();
58
75
  if (error)
59
76
  throw (0, errors_1.mapPostgrestError)(error);
60
- return data;
77
+ return toLeaveType(data);
61
78
  }
62
79
  async create(input) {
63
80
  const row = await this.table.insert({
@@ -66,7 +83,16 @@ class LeaveTypesResource {
66
83
  default_days: input.default_days,
67
84
  is_paid: input.is_paid,
68
85
  requires_approval: input.requires_approval,
69
- requires_document: input.requires_document,
86
+ document_after_days: input.document_after_days,
87
+ cycle_years: input.cycle_years,
88
+ grant_method: input.grant_method,
89
+ max_carry_over_days: input.max_carry_over_days,
90
+ carry_over_expiry_months: input.carry_over_expiry_months,
91
+ min_service_months: input.min_service_months,
92
+ min_days_per_week: input.min_days_per_week,
93
+ initial_months: input.initial_months,
94
+ initial_days_per_month: input.initial_days_per_month,
95
+ paid_on_exit: input.paid_on_exit,
70
96
  organization_id: this.auth.organization_id,
71
97
  created_by: this.auth.mms_id,
72
98
  created_at: new Date().toISOString(),
@@ -80,7 +106,16 @@ class LeaveTypesResource {
80
106
  default_days: input.default_days,
81
107
  is_paid: input.is_paid,
82
108
  requires_approval: input.requires_approval,
83
- requires_document: input.requires_document,
109
+ document_after_days: input.document_after_days,
110
+ cycle_years: input.cycle_years,
111
+ grant_method: input.grant_method,
112
+ max_carry_over_days: input.max_carry_over_days,
113
+ carry_over_expiry_months: input.carry_over_expiry_months,
114
+ min_service_months: input.min_service_months,
115
+ min_days_per_week: input.min_days_per_week,
116
+ initial_months: input.initial_months,
117
+ initial_days_per_month: input.initial_days_per_month,
118
+ paid_on_exit: input.paid_on_exit,
84
119
  active: input.active,
85
120
  last_updated_by: this.auth.mms_id,
86
121
  updated_at: new Date().toISOString(),
@@ -4,6 +4,8 @@ import type { LeavePeriod } from '@managemint-solutions/entities/leave';
4
4
  * customer is South African; this is the one place to change when a column arrives.
5
5
  */
6
6
  export declare const LEAVE_TIME_ZONE = "Africa/Johannesburg";
7
+ /** `users.working_days`' default: ISO weekdays, Monday (1) to Friday (5). */
8
+ export declare const MONDAY_TO_FRIDAY: readonly number[];
7
9
  /** A `Date` at UTC midnight of a `YYYY-MM-DD` string (a longer ISO string is cut to its date). */
8
10
  export declare const parseIsoDate: (value: string) => Date;
9
11
  /** The `YYYY-MM-DD` of a `Date`'s UTC calendar day. */
@@ -12,22 +14,24 @@ export declare const toIsoDate: (date: Date) => string;
12
14
  export declare const todayInJohannesburg: (now?: Date) => string;
13
15
  /** Every calendar date from `start` to `end` inclusive; empty when `end` is before `start`. */
14
16
  export declare const eachDate: (start: string, end: string) => string[];
15
- /** Monday to Friday and not one of the organization's holidays. */
16
- export declare const isWorkingDay: (isoDate: string, holidays: ReadonlySet<string>) => boolean;
17
+ /** One of the person's `workingDays` (ISO weekdays) and not a public holiday. */
18
+ export declare const isWorkingDay: (isoDate: string, holidays: ReadonlySet<string>, workingDays?: readonly number[]) => boolean;
17
19
  /**
18
20
  * The days a request is worth: the working days from `start` to `end` inclusive, halved for a
19
21
  * half day. A half day is only meaningful on a single working day, so it yields 0.5 there and 0
20
- * on a weekend or holiday - the caller rejects a zero either way.
22
+ * on a day off or holiday - the caller rejects a zero either way.
21
23
  */
22
- export declare const countWorkingDays: (start: string, end: string, holidays: ReadonlySet<string>, isHalfDay?: boolean) => number;
24
+ export declare const countWorkingDays: (start: string, end: string, holidays: ReadonlySet<string>, isHalfDay?: boolean, workingDays?: readonly number[]) => number;
23
25
  /** `isoDate` moved by `days` calendar days, either way; `shiftDays('2026-03-01', -1)` is Feb 28. */
24
26
  export declare const shiftDays: (isoDate: string, days: number) => string;
25
- /** The day before the same date next year - the end of a period that starts on `isoDate`. */
26
- export declare const addOneYearMinusDay: (isoDate: string) => string;
27
27
  /**
28
- * The leave year containing `date` for an organization whose year starts on the first of
29
- * `startMonth` (1-12, `organizations.leave_calendar_start_month`). A March start puts
30
- * 2026-02-10 in the year that began 2025-03-01.
28
+ * `isoDate` moved by `months` calendar months, the day clamped to the target month's length:
29
+ * `addMonths('2026-01-31', 1)` is Feb 28.
31
30
  */
32
- export declare const leavePeriodFor: (date: string, startMonth: number) => LeavePeriod;
31
+ export declare const addMonths: (isoDate: string, months: number) => string;
32
+ /**
33
+ * Whole months from `start` completed by the end of `asOf`: 0 until the day before the
34
+ * one-month mark, 1 from then. A month counts once its last day has been lived.
35
+ */
36
+ export declare const completedMonths: (start: string, asOf: string) => number;
33
37
  export declare const isWithinPeriod: (date: string, period: LeavePeriod) => boolean;
@@ -1,16 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isWithinPeriod = exports.leavePeriodFor = exports.addOneYearMinusDay = exports.shiftDays = exports.countWorkingDays = exports.isWorkingDay = exports.eachDate = exports.todayInJohannesburg = exports.toIsoDate = exports.parseIsoDate = exports.LEAVE_TIME_ZONE = void 0;
4
- // The calendar arithmetic behind leave: which days of a request count, and which leave year a
5
- // date belongs to. Pure functions over `YYYY-MM-DD` strings - the columns are `date`, so a
3
+ exports.isWithinPeriod = exports.completedMonths = exports.addMonths = exports.shiftDays = exports.countWorkingDays = exports.isWorkingDay = exports.eachDate = exports.todayInJohannesburg = exports.toIsoDate = exports.parseIsoDate = exports.MONDAY_TO_FRIDAY = exports.LEAVE_TIME_ZONE = void 0;
4
+ // The calendar arithmetic behind leave: which days of a request count, and how dates move by
5
+ // days and months. Pure functions over `YYYY-MM-DD` strings - the columns are `date`, so a
6
6
  // request never carries a time and nothing here depends on the caller's clock zone.
7
7
  /**
8
8
  * Where "today" is read for the cancel rule. Organizations carry no time zone yet, and every
9
9
  * customer is South African; this is the one place to change when a column arrives.
10
10
  */
11
11
  exports.LEAVE_TIME_ZONE = 'Africa/Johannesburg';
12
- /** Saturday and Sunday. Not configurable yet - a working-week setting is a later feature. */
13
- const WEEKEND_DAYS = new Set([0, 6]);
12
+ /** `users.working_days`' default: ISO weekdays, Monday (1) to Friday (5). */
13
+ exports.MONDAY_TO_FRIDAY = [1, 2, 3, 4, 5];
14
14
  const pad = (value) => String(value).padStart(2, '0');
15
15
  /** A `Date` at UTC midnight of a `YYYY-MM-DD` string (a longer ISO string is cut to its date). */
16
16
  const parseIsoDate = (value) => {
@@ -41,16 +41,16 @@ const eachDate = (start, end) => {
41
41
  return dates;
42
42
  };
43
43
  exports.eachDate = eachDate;
44
- /** Monday to Friday and not one of the organization's holidays. */
45
- const isWorkingDay = (isoDate, holidays) => !WEEKEND_DAYS.has((0, exports.parseIsoDate)(isoDate).getUTCDay()) && !holidays.has(isoDate.slice(0, 10));
44
+ /** One of the person's `workingDays` (ISO weekdays) and not a public holiday. */
45
+ const isWorkingDay = (isoDate, holidays, workingDays = exports.MONDAY_TO_FRIDAY) => workingDays.includes((0, exports.parseIsoDate)(isoDate).getUTCDay() || 7) && !holidays.has(isoDate.slice(0, 10));
46
46
  exports.isWorkingDay = isWorkingDay;
47
47
  /**
48
48
  * The days a request is worth: the working days from `start` to `end` inclusive, halved for a
49
49
  * half day. A half day is only meaningful on a single working day, so it yields 0.5 there and 0
50
- * on a weekend or holiday - the caller rejects a zero either way.
50
+ * on a day off or holiday - the caller rejects a zero either way.
51
51
  */
52
- const countWorkingDays = (start, end, holidays, isHalfDay = false) => {
53
- const working = (0, exports.eachDate)(start, end).filter((date) => (0, exports.isWorkingDay)(date, holidays)).length;
52
+ const countWorkingDays = (start, end, holidays, isHalfDay = false, workingDays = exports.MONDAY_TO_FRIDAY) => {
53
+ const working = (0, exports.eachDate)(start, end).filter((date) => (0, exports.isWorkingDay)(date, holidays, workingDays)).length;
54
54
  return isHalfDay ? working * 0.5 : working;
55
55
  };
56
56
  exports.countWorkingDays = countWorkingDays;
@@ -61,26 +61,33 @@ const shiftDays = (isoDate, days) => {
61
61
  return (0, exports.toIsoDate)(date);
62
62
  };
63
63
  exports.shiftDays = shiftDays;
64
- /** The day before the same date next year - the end of a period that starts on `isoDate`. */
65
- const addOneYearMinusDay = (isoDate) => {
64
+ /**
65
+ * `isoDate` moved by `months` calendar months, the day clamped to the target month's length:
66
+ * `addMonths('2026-01-31', 1)` is Feb 28.
67
+ */
68
+ const addMonths = (isoDate, months) => {
66
69
  const date = (0, exports.parseIsoDate)(isoDate);
67
- date.setUTCFullYear(date.getUTCFullYear() + 1);
68
- date.setUTCDate(date.getUTCDate() - 1);
70
+ const day = date.getUTCDate();
71
+ date.setUTCDate(1);
72
+ date.setUTCMonth(date.getUTCMonth() + months);
73
+ const lastDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
74
+ date.setUTCDate(Math.min(day, lastDay));
69
75
  return (0, exports.toIsoDate)(date);
70
76
  };
71
- exports.addOneYearMinusDay = addOneYearMinusDay;
77
+ exports.addMonths = addMonths;
72
78
  /**
73
- * The leave year containing `date` for an organization whose year starts on the first of
74
- * `startMonth` (1-12, `organizations.leave_calendar_start_month`). A March start puts
75
- * 2026-02-10 in the year that began 2025-03-01.
79
+ * Whole months from `start` completed by the end of `asOf`: 0 until the day before the
80
+ * one-month mark, 1 from then. A month counts once its last day has been lived.
76
81
  */
77
- const leavePeriodFor = (date, startMonth) => {
78
- const parsed = (0, exports.parseIsoDate)(date);
79
- const year = parsed.getUTCMonth() + 1 >= startMonth ? parsed.getUTCFullYear() : parsed.getUTCFullYear() - 1;
80
- const period_start = (0, exports.toIsoDate)(new Date(Date.UTC(year, startMonth - 1, 1)));
81
- return { period_start, period_end: (0, exports.addOneYearMinusDay)(period_start) };
82
+ const completedMonths = (start, asOf) => {
83
+ const dayAfter = (0, exports.shiftDays)(asOf, 1);
84
+ let months = 0;
85
+ while ((0, exports.addMonths)(start, months + 1) <= dayAfter) {
86
+ months += 1;
87
+ }
88
+ return months;
82
89
  };
83
- exports.leavePeriodFor = leavePeriodFor;
90
+ exports.completedMonths = completedMonths;
84
91
  const isWithinPeriod = (date, period) => {
85
92
  const day = date.slice(0, 10);
86
93
  return day >= period.period_start && day <= period.period_end;
@@ -59,4 +59,5 @@ export declare class UpdateOrganizationDto implements UpdateOrganizationDtoType
59
59
  readonly industry?: string | null;
60
60
  readonly marketing_consent?: boolean;
61
61
  readonly currency?: string;
62
+ readonly leave_calendar_start_month?: number;
62
63
  }
@@ -225,6 +225,7 @@ class UpdateOrganizationDto {
225
225
  industry;
226
226
  marketing_consent;
227
227
  currency;
228
+ leave_calendar_start_month;
228
229
  }
229
230
  exports.UpdateOrganizationDto = UpdateOrganizationDto;
230
231
  __decorate([
@@ -442,3 +443,10 @@ __decorate([
442
443
  (0, class_validator_1.MaxLength)(255, { message: 'Organization currency must be less than 255 characters' }),
443
444
  __metadata("design:type", String)
444
445
  ], UpdateOrganizationDto.prototype, "currency", void 0);
446
+ __decorate([
447
+ (0, class_validator_1.IsOptional)(),
448
+ (0, class_validator_1.IsInt)({ message: 'Leave year start month must be a whole number' }),
449
+ (0, class_validator_1.Min)(1, { message: 'Leave year start month must be between 1 and 12' }),
450
+ (0, class_validator_1.Max)(12, { message: 'Leave year start month must be between 1 and 12' }),
451
+ __metadata("design:type", Number)
452
+ ], UpdateOrganizationDto.prototype, "leave_calendar_start_month", void 0);
@@ -46,6 +46,11 @@ export declare class SupabaseOrganizationScope {
46
46
  * subscriptions and both webhook handler services.
47
47
  */
48
48
  table(options: AuditedTableOptions): AuditedTable;
49
+ /**
50
+ * Gives `userId` a new leave calendar feed token (stored as its SHA-256), which also retires
51
+ * any address they subscribed with before. Service role only: the token is a credential.
52
+ */
53
+ replaceLeaveCalendarFeed(userId: string, tokenHash: string): Promise<void>;
49
54
  /**
50
55
  * Clients written service-role on a user's behalf: converting a lead needs only
51
56
  * `update_leads`, so the client it becomes is created here rather than through the caller's
@@ -97,14 +102,23 @@ export declare class SupabaseServiceClient {
97
102
  */
98
103
  organizationsWithPaystackCustomer(): Promise<string[]>;
99
104
  /**
100
- * Every organization whose leave year starts on the first of `month` (1-12) - the tenants the
101
- * nightly rollover visits on that day. Unscoped like the reconcile: the cron picks its tenants
102
- * from here. `modules` comes along so the caller can skip an organization without HR.
105
+ * Every organization subscribed to `moduleKey`, with its leave-year start month - the tenants
106
+ * the nightly leave rollover visits. Unscoped like the reconcile: the cron picks its tenants
107
+ * from here.
103
108
  */
104
- organizationsWithLeaveYearStartingIn(month: number): Promise<{
109
+ organizationsWithModule(moduleKey: string): Promise<{
105
110
  mms_id: string;
106
- modules: string[];
111
+ leave_calendar_start_month: number;
107
112
  }[]>;
113
+ /**
114
+ * Whose leave calendar feed a token opens, by the token's SHA-256. Unscoped by necessity: the
115
+ * feed is fetched by a calendar app with no session, and the row is what says which tenant
116
+ * and person it belongs to. `leave_calendar_feeds` has no `authenticated` grant at all.
117
+ */
118
+ leaveCalendarFeedFor(tokenHash: string): Promise<{
119
+ user_id: string;
120
+ organization_id: string;
121
+ } | null>;
108
122
  /**
109
123
  * A ledger row by id alone. Also unscoped by necessity: a `charge.success` carries the
110
124
  * transaction id in its metadata, and the row is what says which tenant it belongs to.
@@ -61,6 +61,20 @@ class SupabaseOrganizationScope {
61
61
  table(options) {
62
62
  return (0, audit_1.auditedTable)(this.supabase, this.actor, this.audit, options);
63
63
  }
64
+ /**
65
+ * Gives `userId` a new leave calendar feed token (stored as its SHA-256), which also retires
66
+ * any address they subscribed with before. Service role only: the token is a credential.
67
+ */
68
+ async replaceLeaveCalendarFeed(userId, tokenHash) {
69
+ const { error } = await this.supabase.from('leave_calendar_feeds').upsert({
70
+ user_id: userId,
71
+ organization_id: this.actor.organization_id,
72
+ token_hash: tokenHash,
73
+ created_at: new Date().toISOString(),
74
+ }, { onConflict: 'user_id' });
75
+ if (error)
76
+ throw (0, errors_1.mapPostgrestError)(error);
77
+ }
64
78
  /**
65
79
  * Clients written service-role on a user's behalf: converting a lead needs only
66
80
  * `update_leads`, so the client it becomes is created here rather than through the caller's
@@ -167,19 +181,34 @@ class SupabaseServiceClient {
167
181
  return (data ?? []).map((row) => row.organization_id);
168
182
  }
169
183
  /**
170
- * Every organization whose leave year starts on the first of `month` (1-12) - the tenants the
171
- * nightly rollover visits on that day. Unscoped like the reconcile: the cron picks its tenants
172
- * from here. `modules` comes along so the caller can skip an organization without HR.
184
+ * Every organization subscribed to `moduleKey`, with its leave-year start month - the tenants
185
+ * the nightly leave rollover visits. Unscoped like the reconcile: the cron picks its tenants
186
+ * from here.
173
187
  */
174
- async organizationsWithLeaveYearStartingIn(month) {
188
+ async organizationsWithModule(moduleKey) {
175
189
  const { data, error } = await this.supabase
176
190
  .from('organizations')
177
- .select('mms_id, modules')
178
- .eq('leave_calendar_start_month', month);
191
+ .select('mms_id, leave_calendar_start_month')
192
+ .contains('modules', [moduleKey]);
179
193
  if (error)
180
194
  throw (0, errors_1.mapPostgrestError)(error);
181
195
  return (data ?? []);
182
196
  }
197
+ /**
198
+ * Whose leave calendar feed a token opens, by the token's SHA-256. Unscoped by necessity: the
199
+ * feed is fetched by a calendar app with no session, and the row is what says which tenant
200
+ * and person it belongs to. `leave_calendar_feeds` has no `authenticated` grant at all.
201
+ */
202
+ async leaveCalendarFeedFor(tokenHash) {
203
+ const { data, error } = await this.supabase
204
+ .from('leave_calendar_feeds')
205
+ .select('user_id, organization_id')
206
+ .eq('token_hash', tokenHash)
207
+ .maybeSingle();
208
+ if (error)
209
+ throw (0, errors_1.mapPostgrestError)(error);
210
+ return (data ?? null);
211
+ }
183
212
  /**
184
213
  * A ledger row by id alone. Also unscoped by necessity: a `charge.success` carries the
185
214
  * transaction id in its metadata, and the row is what says which tenant it belongs to.
@@ -1,4 +1,4 @@
1
- import type { AddStatusDto as AddStatusDtoType, GetStatusesDto as GetStatusesDtoType, StatusIdDto as StatusIdDtoType, UpdateStatusDto as UpdateStatusDtoType } from '@managemint-solutions/entities/status/dto';
1
+ import type { AddStatusDto as AddStatusDtoType, GetStatusesDto as GetStatusesDtoType, ReorderStatusesDto as ReorderStatusesDtoType, StatusIdDto as StatusIdDtoType, UpdateStatusDto as UpdateStatusDtoType } from '@managemint-solutions/entities/status/dto';
2
2
  import { StatusEntityTypes } from '@managemint-solutions/entities/status/enum';
3
3
  export declare class StatusIdDto implements StatusIdDtoType {
4
4
  readonly statusId: string;
@@ -12,6 +12,9 @@ export declare class AddStatusDto implements AddStatusDtoType {
12
12
  readonly entity: StatusEntityTypes;
13
13
  readonly is_complete?: boolean;
14
14
  }
15
+ export declare class ReorderStatusesDto implements ReorderStatusesDtoType {
16
+ readonly status_ids: string[];
17
+ }
15
18
  export declare class UpdateStatusDto implements UpdateStatusDtoType {
16
19
  readonly label: string;
17
20
  readonly colour: string;
@@ -9,7 +9,7 @@ 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.UpdateStatusDto = exports.AddStatusDto = exports.GetStatusesDto = exports.StatusIdDto = void 0;
12
+ exports.UpdateStatusDto = exports.ReorderStatusesDto = exports.AddStatusDto = exports.GetStatusesDto = exports.StatusIdDto = void 0;
13
13
  const class_validator_1 = require("class-validator");
14
14
  const enum_1 = require("@managemint-solutions/entities/status/enum");
15
15
  // class-validator versions of the shared DTO types from @managemint-solutions/entities.
@@ -62,6 +62,18 @@ __decorate([
62
62
  (0, class_validator_1.IsBoolean)({ message: 'is_complete must be true or false' }),
63
63
  __metadata("design:type", Boolean)
64
64
  ], AddStatusDto.prototype, "is_complete", void 0);
65
+ class ReorderStatusesDto {
66
+ // The whole catalogue of the entity type in its new order; the resource checks it is complete
67
+ // and has no repeats, which the decorators cannot (they do not know the organization).
68
+ status_ids;
69
+ }
70
+ exports.ReorderStatusesDto = ReorderStatusesDto;
71
+ __decorate([
72
+ (0, class_validator_1.IsArray)({ message: 'status_ids must be an array' }),
73
+ (0, class_validator_1.ArrayMinSize)(1, { message: 'status_ids cannot be empty' }),
74
+ (0, class_validator_1.IsUUID)('7', { each: true, message: 'Invalid status ID format' }),
75
+ __metadata("design:type", Array)
76
+ ], ReorderStatusesDto.prototype, "status_ids", void 0);
65
77
  class UpdateStatusDto {
66
78
  label;
67
79
  // Colour must be a valid hex code (e.g., #FFFFFF)
@@ -1,5 +1,5 @@
1
1
  import type { StatusEntity } from '@managemint-solutions/entities/status';
2
- import type { AddStatusDto, UpdateStatusDto } from '@managemint-solutions/entities/status/dto';
2
+ import type { AddStatusDto, ReorderStatusesDto, UpdateStatusDto } from '@managemint-solutions/entities/status/dto';
3
3
  import type { StatusEntityTypes } from '@managemint-solutions/entities/status/enum';
4
4
  import type { AuthContext } from '../auth';
5
5
  import { type AuditResource } from '../audit';
@@ -10,8 +10,17 @@ export declare class StatusesResource {
10
10
  private readonly auth;
11
11
  private readonly table;
12
12
  constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
13
+ /** The catalogue in its order: `position` first, then insertion order for the seeded ties. */
13
14
  list(entity: StatusEntityTypes): Promise<StatusEntity[]>;
15
+ /** A new status goes last: one past the highest position its entity type has. */
14
16
  create(input: AddStatusDto): Promise<StatusEntity>;
17
+ /**
18
+ * Rewrites the whole catalogue's positions from the order given. The ids must be exactly the
19
+ * entity type's statuses, each once - a partial list would leave positions undefined, so it is
20
+ * refused with a 400 before anything is written. Only the rows that move are written (and
21
+ * audited); the answer is the catalogue in its new order.
22
+ */
23
+ reorder(entity: StatusEntityTypes, input: ReorderStatusesDto): Promise<StatusEntity[]>;
15
24
  update(statusId: string, input: UpdateStatusDto): Promise<StatusEntity>;
16
25
  /** The Won status cannot be deleted. Every other status can. */
17
26
  remove(statusId: string): Promise<void>;
@@ -23,7 +23,7 @@ const refuseWon = (message) => new errors_1.SupabaseClientError({ status: 400, e
23
23
  // The public contract is the shared StatusEntity from @managemint-solutions/entities; the
24
24
  // `as StatusEntity` casts declare the shape the select string below produces (the columns are
25
25
  // defined in the api's supabase/migrations).
26
- const STATUS_SELECT = 'mms_id, label, colour, is_complete, is_fail, is_triage, is_won';
26
+ const STATUS_SELECT = 'mms_id, label, colour, is_complete, is_fail, is_triage, is_won, position';
27
27
  // The audited write returns the whole row; the wire shape is the STATUS_SELECT columns.
28
28
  const toStatus = (row) => ({
29
29
  mms_id: row.mms_id,
@@ -33,6 +33,7 @@ const toStatus = (row) => ({
33
33
  is_fail: row.is_fail,
34
34
  is_triage: row.is_triage,
35
35
  is_won: row.is_won,
36
+ position: row.position,
36
37
  });
37
38
  class StatusesResource {
38
39
  supabase;
@@ -46,27 +47,75 @@ class StatusesResource {
46
47
  entityType: enum_1.AuditTrailEntityType.STATUS,
47
48
  });
48
49
  }
50
+ /** The catalogue in its order: `position` first, then insertion order for the seeded ties. */
49
51
  async list(entity) {
50
52
  const { data, error } = await this.supabase
51
53
  .from('statuses')
52
54
  .select(STATUS_SELECT)
53
55
  .eq('organization_id', this.auth.organization_id)
54
- .eq('entity_type', entity);
56
+ .eq('entity_type', entity)
57
+ .order('position')
58
+ .order('created_at')
59
+ .order('mms_id');
55
60
  if (error)
56
61
  throw (0, errors_1.mapPostgrestError)(error);
57
62
  return data;
58
63
  }
64
+ /** A new status goes last: one past the highest position its entity type has. */
59
65
  async create(input) {
66
+ const { data, error } = await this.supabase
67
+ .from('statuses')
68
+ .select('position')
69
+ .eq('organization_id', this.auth.organization_id)
70
+ .eq('entity_type', input.entity)
71
+ .order('position', { ascending: false })
72
+ .limit(1)
73
+ .maybeSingle();
74
+ if (error)
75
+ throw (0, errors_1.mapPostgrestError)(error);
76
+ const last = data?.position;
60
77
  const row = await this.table.insert({
61
78
  label: input.label,
62
79
  colour: input.colour,
63
80
  entity_type: input.entity,
64
81
  is_complete: input.is_complete ?? false,
82
+ position: last === undefined ? 0 : last + 1,
65
83
  organization_id: this.auth.organization_id,
66
84
  created_by: this.auth.mms_id,
67
85
  });
68
86
  return toStatus(row);
69
87
  }
88
+ /**
89
+ * Rewrites the whole catalogue's positions from the order given. The ids must be exactly the
90
+ * entity type's statuses, each once - a partial list would leave positions undefined, so it is
91
+ * refused with a 400 before anything is written. Only the rows that move are written (and
92
+ * audited); the answer is the catalogue in its new order.
93
+ */
94
+ async reorder(entity, input) {
95
+ const current = await this.list(entity);
96
+ const currentIds = new Set(current.map((status) => status.mms_id));
97
+ const given = new Set(input.status_ids);
98
+ if (given.size !== input.status_ids.length ||
99
+ given.size !== currentIds.size ||
100
+ input.status_ids.some((id) => !currentIds.has(id))) {
101
+ throw new errors_1.SupabaseClientError({
102
+ status: 400,
103
+ error: 'Database Error',
104
+ message: `status_ids must list every ${entity} status exactly once`,
105
+ });
106
+ }
107
+ const positionOf = new Map(current.map((status) => [status.mms_id, status.position]));
108
+ for (const [position, id] of input.status_ids.entries()) {
109
+ if (positionOf.get(id) === position)
110
+ continue;
111
+ await this.table.update(id, {
112
+ position,
113
+ updated_at: new Date().toISOString(),
114
+ last_updated_by: this.auth.mms_id,
115
+ });
116
+ }
117
+ return this.list(entity);
118
+ }
70
119
  async update(statusId, input) {
71
120
  if (await this.isWon(statusId)) {
72
121
  throw refuseWon('The Won status is fixed and cannot be changed');
@@ -26,6 +26,7 @@ export declare class GetUsersQueryDto implements Omit<GetUsersDtoType, 'active_i
26
26
  readonly page: number;
27
27
  readonly page_size: number;
28
28
  readonly active_in?: boolean[] | null;
29
+ readonly manager_id?: string | null;
29
30
  }
30
31
  export declare class UpdateUserDto implements UpdateUserDtoType {
31
32
  readonly name: string;
@@ -39,6 +40,7 @@ export declare class UpdateUserDto implements UpdateUserDtoType {
39
40
  readonly job_title?: string | null;
40
41
  readonly manager_id?: string | null;
41
42
  readonly secondary_manager_id?: string | null;
43
+ readonly working_days?: number[];
42
44
  readonly social_facebook?: string | null;
43
45
  readonly social_linkedin?: string | null;
44
46
  readonly social_twitter?: string | null;