@managemint-solutions/sdk 0.52.0 → 0.54.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 +92 -13
- package/dist/additional-details/index.d.ts +2 -2
- package/dist/additional-details/index.js +6 -10
- package/dist/audit/dto.d.ts +3 -8
- package/dist/audit/dto.js +7 -48
- package/dist/audit/index.d.ts +71 -10
- package/dist/audit/index.js +234 -55
- package/dist/auth-client/index.d.ts +2 -0
- package/dist/auth-client/index.js +12 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +47 -3
- package/dist/clients/dto.d.ts +5 -1
- package/dist/clients/dto.js +12 -1
- package/dist/clients/index.d.ts +8 -4
- package/dist/clients/index.js +11 -8
- package/dist/emulation-handshakes/index.d.ts +13 -0
- package/dist/emulation-handshakes/index.js +36 -0
- package/dist/errors.js +2 -2
- package/dist/index.d.ts +14 -10
- package/dist/index.js +23 -4
- package/dist/leads/dto.d.ts +5 -1
- package/dist/leads/dto.js +12 -1
- package/dist/leads/index.d.ts +8 -5
- package/dist/leads/index.js +23 -21
- package/dist/leave/leave-adjustments.js +1 -0
- package/dist/leave/leave-entitlements.d.ts +3 -1
- package/dist/leave/leave-entitlements.js +3 -0
- package/dist/leave/leave-requests.d.ts +3 -1
- package/dist/leave/leave-requests.js +3 -0
- package/dist/nest/index.d.ts +26 -1
- package/dist/nest/index.js +58 -1
- package/dist/notifications/index.d.ts +1 -3
- package/dist/organization-payment-methods/index.d.ts +5 -6
- package/dist/organization-payment-methods/index.js +4 -17
- package/dist/organizations/dto.d.ts +9 -1
- package/dist/organizations/dto.js +40 -1
- package/dist/organizations/index.d.ts +13 -3
- package/dist/organizations/index.js +59 -3
- package/dist/permissions/index.d.ts +6 -4
- package/dist/permissions/index.js +19 -12
- package/dist/projects/dto.d.ts +5 -1
- package/dist/projects/dto.js +12 -1
- package/dist/projects/index.d.ts +8 -4
- package/dist/projects/index.js +11 -8
- package/dist/service-client/index.d.ts +39 -1
- package/dist/service-client/index.js +89 -2
- package/dist/staff/dto.d.ts +14 -0
- package/dist/staff/dto.js +63 -0
- package/dist/staff/index.d.ts +50 -0
- package/dist/staff/index.js +139 -0
- package/dist/staff-client.d.ts +37 -0
- package/dist/staff-client.js +52 -0
- package/dist/tasks/dto.d.ts +5 -1
- package/dist/tasks/dto.js +12 -1
- package/dist/tasks/index.d.ts +15 -10
- package/dist/tasks/index.js +34 -31
- package/dist/tenancy.d.ts +13 -0
- package/dist/tenancy.js +25 -0
- package/dist/timesheets/timesheet-entries.js +1 -0
- package/dist/timesheets/timesheets.d.ts +3 -1
- package/dist/timesheets/timesheets.js +3 -0
- package/dist/user-api-credentials/dto.d.ts +12 -0
- package/dist/user-api-credentials/dto.js +56 -0
- package/dist/user-api-credentials/index.d.ts +47 -0
- package/dist/user-api-credentials/index.js +101 -0
- package/dist/users/dto.d.ts +5 -1
- package/dist/users/dto.js +13 -2
- package/dist/users/index.d.ts +7 -3
- package/dist/users/index.js +16 -29
- package/dist/validators.d.ts +2 -0
- package/dist/validators.js +6 -1
- package/package.json +2 -2
package/dist/nest/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { HttpException } from '@nestjs/common';
|
|
2
2
|
import type { Request } from 'express';
|
|
3
3
|
import type { AuditErrorHandler } from '../audit';
|
|
4
|
-
import { type VerifiedAuth } from '../auth';
|
|
4
|
+
import { type VerifiedAuth, type VerifiedStaff } from '../auth';
|
|
5
5
|
import { type SupabaseClient } from '../client';
|
|
6
6
|
import { SupabaseClientError } from '../errors';
|
|
7
|
+
import { type SupabaseStaffClient as StaffClient } from '../staff-client';
|
|
7
8
|
import { type SupabaseServiceClient } from '../service-client';
|
|
8
9
|
/**
|
|
9
10
|
* The HttpException Nest should send for an SDK error: same status, and the body is
|
|
@@ -42,6 +43,30 @@ export declare const attachSupabaseRequestContext: (request: Request) => Promise
|
|
|
42
43
|
export declare const getSupabaseRequestContext: (request: Request) => SupabaseRequestContext;
|
|
43
44
|
/** The caller's data client, from the context the auth guard attached. */
|
|
44
45
|
export declare const SupabaseUserClient: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
46
|
+
/** What the admin api's staff guard leaves on the request for the decorator to pick up. */
|
|
47
|
+
export type SupabaseStaffRequestContext = {
|
|
48
|
+
client: StaffClient;
|
|
49
|
+
staff: VerifiedStaff;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Where on the request the staff context is stored - `request[SUPABASE_STAFF_REQUEST_KEY]`. A key
|
|
53
|
+
* of its own, so a staff context is never found where a tenant one is expected.
|
|
54
|
+
*/
|
|
55
|
+
export declare const SUPABASE_STAFF_REQUEST_KEY = "supabaseStaff";
|
|
56
|
+
/**
|
|
57
|
+
* `attachSupabaseRequestContext` for a staff session: verifies the token as a staff one and builds
|
|
58
|
+
* the staff member's `SupabaseStaffClient`.
|
|
59
|
+
*
|
|
60
|
+
* Idempotent: a second call hands back the context the first one attached.
|
|
61
|
+
*/
|
|
62
|
+
export declare const attachSupabaseStaffRequestContext: (request: Request) => Promise<SupabaseStaffRequestContext>;
|
|
63
|
+
/**
|
|
64
|
+
* The context the staff guard attached. Throws when it is absent, which only happens when the
|
|
65
|
+
* route is missing the guard.
|
|
66
|
+
*/
|
|
67
|
+
export declare const getSupabaseStaffRequestContext: (request: Request) => SupabaseStaffRequestContext;
|
|
68
|
+
/** The staff member's data client, from the context the staff guard attached. */
|
|
69
|
+
export declare const SupabaseStaffClient: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
45
70
|
/**
|
|
46
71
|
* A `SupabaseServiceClient` from the ambient env - the SDK-side replacement for the api's
|
|
47
72
|
* `createSupabaseAdminClient()`.
|
package/dist/nest/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SupabasePublicClient = exports.createSupabaseServiceClientFromEnv = exports.SupabaseUserClient = exports.getSupabaseRequestContext = exports.attachSupabaseRequestContext = exports.SUPABASE_REQUEST_KEY = exports.configureSupabaseUserClient = exports.toHttpException = void 0;
|
|
3
|
+
exports.SupabasePublicClient = exports.createSupabaseServiceClientFromEnv = exports.SupabaseStaffClient = exports.getSupabaseStaffRequestContext = exports.attachSupabaseStaffRequestContext = exports.SUPABASE_STAFF_REQUEST_KEY = exports.SupabaseUserClient = exports.getSupabaseRequestContext = exports.attachSupabaseRequestContext = exports.SUPABASE_REQUEST_KEY = exports.configureSupabaseUserClient = exports.toHttpException = void 0;
|
|
4
4
|
const common_1 = require("@nestjs/common");
|
|
5
5
|
const auth_1 = require("../auth");
|
|
6
6
|
const auth_client_1 = require("../auth-client");
|
|
7
7
|
const client_1 = require("../client");
|
|
8
8
|
const errors_1 = require("../errors");
|
|
9
|
+
// Aliased: the param decorator below takes the class's name.
|
|
10
|
+
const staff_client_1 = require("../staff-client");
|
|
9
11
|
const service_client_1 = require("../service-client");
|
|
10
12
|
/**
|
|
11
13
|
* The HttpException Nest should send for an SDK error: same status, and the body is
|
|
@@ -96,6 +98,61 @@ const getSupabaseRequestContext = (request) => {
|
|
|
96
98
|
exports.getSupabaseRequestContext = getSupabaseRequestContext;
|
|
97
99
|
/** The caller's data client, from the context the auth guard attached. */
|
|
98
100
|
exports.SupabaseUserClient = (0, common_1.createParamDecorator)((_, ctx) => (0, exports.getSupabaseRequestContext)(ctx.switchToHttp().getRequest()).client);
|
|
101
|
+
/**
|
|
102
|
+
* Where on the request the staff context is stored - `request[SUPABASE_STAFF_REQUEST_KEY]`. A key
|
|
103
|
+
* of its own, so a staff context is never found where a tenant one is expected.
|
|
104
|
+
*/
|
|
105
|
+
exports.SUPABASE_STAFF_REQUEST_KEY = 'supabaseStaff';
|
|
106
|
+
/**
|
|
107
|
+
* `attachSupabaseRequestContext` for a staff session: verifies the token as a staff one and builds
|
|
108
|
+
* the staff member's `SupabaseStaffClient`.
|
|
109
|
+
*
|
|
110
|
+
* Idempotent: a second call hands back the context the first one attached.
|
|
111
|
+
*/
|
|
112
|
+
const attachSupabaseStaffRequestContext = async (request) => {
|
|
113
|
+
const existing = request[exports.SUPABASE_STAFF_REQUEST_KEY];
|
|
114
|
+
if (existing)
|
|
115
|
+
return existing;
|
|
116
|
+
const accessToken = getBearerToken(request);
|
|
117
|
+
const url = getEnvOrThrow('SUPABASE_URL');
|
|
118
|
+
const key = getEnvOrThrow('SUPABASE_PUBLISHABLE_KEY');
|
|
119
|
+
const client = (0, auth_1.createRawClient)(url, key, accessToken);
|
|
120
|
+
try {
|
|
121
|
+
const staff = await (0, auth_1.verifyStaffAccessToken)({ url, key, accessToken, client });
|
|
122
|
+
const context = {
|
|
123
|
+
staff,
|
|
124
|
+
client: (0, staff_client_1.createSupabaseStaffClient)({
|
|
125
|
+
url,
|
|
126
|
+
key,
|
|
127
|
+
accessToken,
|
|
128
|
+
client,
|
|
129
|
+
auth: { mms_id: staff.mms_id, organization_id: null },
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
request[exports.SUPABASE_STAFF_REQUEST_KEY] = context;
|
|
133
|
+
return context;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
// Converted here for the same reason as the tenant attach: guards run outside the filters.
|
|
137
|
+
if (error instanceof errors_1.SupabaseClientError)
|
|
138
|
+
throw (0, exports.toHttpException)(error);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
exports.attachSupabaseStaffRequestContext = attachSupabaseStaffRequestContext;
|
|
143
|
+
/**
|
|
144
|
+
* The context the staff guard attached. Throws when it is absent, which only happens when the
|
|
145
|
+
* route is missing the guard.
|
|
146
|
+
*/
|
|
147
|
+
const getSupabaseStaffRequestContext = (request) => {
|
|
148
|
+
const context = request[exports.SUPABASE_STAFF_REQUEST_KEY];
|
|
149
|
+
if (!context)
|
|
150
|
+
throw new common_1.UnauthorizedException('Request is not authenticated');
|
|
151
|
+
return context;
|
|
152
|
+
};
|
|
153
|
+
exports.getSupabaseStaffRequestContext = getSupabaseStaffRequestContext;
|
|
154
|
+
/** The staff member's data client, from the context the staff guard attached. */
|
|
155
|
+
exports.SupabaseStaffClient = (0, common_1.createParamDecorator)((_, ctx) => (0, exports.getSupabaseStaffRequestContext)(ctx.switchToHttp().getRequest()).client);
|
|
99
156
|
/**
|
|
100
157
|
* A `SupabaseServiceClient` from the ambient env - the SDK-side replacement for the api's
|
|
101
158
|
* `createSupabaseAdminClient()`.
|
|
@@ -2,7 +2,7 @@ import type { NotificationContent, NotificationEntity, NotificationSummary, Noti
|
|
|
2
2
|
import { NotificationChannel, NotificationStatus, type NotificationType } from '@managemint-solutions/entities/notifications/enum';
|
|
3
3
|
import type { MmsList } from '@managemint-solutions/entities/common';
|
|
4
4
|
import type { AuthContext } from '../auth';
|
|
5
|
-
import { type
|
|
5
|
+
import { type AuditResource, type OrganizationTableFactory } from '../audit';
|
|
6
6
|
import type { TypedSupabaseClient } from '../client';
|
|
7
7
|
import type { GetNotificationsDto } from './dto';
|
|
8
8
|
export * from './dto';
|
|
@@ -75,8 +75,6 @@ export declare class NotificationsResource {
|
|
|
75
75
|
/** Soft delete - the outbox row stays, flagged `deleted`, so the send record survives. */
|
|
76
76
|
remove(notificationId: string): Promise<void>;
|
|
77
77
|
}
|
|
78
|
-
/** How the outbox reaches an audited table once it knows which organization a row belongs to. */
|
|
79
|
-
export type OrganizationTableFactory = (organizationId: string) => AuditedTable;
|
|
80
78
|
/**
|
|
81
79
|
* The outbox and the queue worker's side of the table, on the service client: RLS reserves
|
|
82
80
|
* `notifications` INSERT and DELETE to the service role, and `authenticated` has no grant on
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AuditActor } from '../audit';
|
|
2
2
|
import type { TypedSupabaseClient } from '../client';
|
|
3
3
|
/**
|
|
4
4
|
* Everything Paystack knows an organization by, plus the two secrets the billing service acts
|
|
@@ -31,15 +31,14 @@ export type PaymentMethodLookupColumn = 'paystack_customer_code' | 'paystack_sub
|
|
|
31
31
|
* policy. Only the service role can reach it.
|
|
32
32
|
*
|
|
33
33
|
* Service-role only, like the invites - this lives on the organization scope of the service
|
|
34
|
-
* client and never on a caller's client. Writes are audited
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* client and never on a caller's client. Writes are not audited: every column is provider
|
|
35
|
+
* machinery, and an organization's subscription history is the billing ledger, not its audit
|
|
36
|
+
* trail (`untracked` on the organizations table, docs/11-audit-trail.md).
|
|
37
37
|
*/
|
|
38
38
|
export declare class OrganizationPaymentMethodsResource {
|
|
39
39
|
private readonly supabase;
|
|
40
40
|
private readonly actor;
|
|
41
|
-
|
|
42
|
-
constructor(supabase: TypedSupabaseClient, actor: AuditActor, audit: AuditResource);
|
|
41
|
+
constructor(supabase: TypedSupabaseClient, actor: AuditActor);
|
|
43
42
|
/** The actor organization's payment method; every column null when nothing has been stored yet. */
|
|
44
43
|
get(): Promise<OrganizationPaymentMethod>;
|
|
45
44
|
/**
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OrganizationPaymentMethodsResource = exports.PAYMENT_METHOD_COLUMNS = void 0;
|
|
4
|
-
const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
5
|
-
const audit_1 = require("../audit");
|
|
6
4
|
const errors_1 = require("../errors");
|
|
7
5
|
exports.PAYMENT_METHOD_COLUMNS = [
|
|
8
6
|
'paystack_customer_code',
|
|
@@ -33,18 +31,16 @@ const EMPTY = {
|
|
|
33
31
|
* policy. Only the service role can reach it.
|
|
34
32
|
*
|
|
35
33
|
* Service-role only, like the invites - this lives on the organization scope of the service
|
|
36
|
-
* client and never on a caller's client. Writes are audited
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* client and never on a caller's client. Writes are not audited: every column is provider
|
|
35
|
+
* machinery, and an organization's subscription history is the billing ledger, not its audit
|
|
36
|
+
* trail (`untracked` on the organizations table, docs/11-audit-trail.md).
|
|
39
37
|
*/
|
|
40
38
|
class OrganizationPaymentMethodsResource {
|
|
41
39
|
supabase;
|
|
42
40
|
actor;
|
|
43
|
-
|
|
44
|
-
constructor(supabase, actor, audit) {
|
|
41
|
+
constructor(supabase, actor) {
|
|
45
42
|
this.supabase = supabase;
|
|
46
43
|
this.actor = actor;
|
|
47
|
-
this.audit = audit;
|
|
48
44
|
}
|
|
49
45
|
/** The actor organization's payment method; every column null when nothing has been stored yet. */
|
|
50
46
|
async get() {
|
|
@@ -73,15 +69,6 @@ class OrganizationPaymentMethodsResource {
|
|
|
73
69
|
}, { onConflict: 'organization_id' });
|
|
74
70
|
if (error)
|
|
75
71
|
throw (0, errors_1.mapPostgrestError)(error);
|
|
76
|
-
const changes = (0, audit_1.diffChanges)(before, after);
|
|
77
|
-
if (Object.keys(changes).length === 0)
|
|
78
|
-
return;
|
|
79
|
-
await this.audit.record({
|
|
80
|
-
action: enum_1.AuditTrailAction.UPDATE,
|
|
81
|
-
entityType: enum_1.AuditTrailEntityType.ORGANIZATION,
|
|
82
|
-
entityId: this.actor.organization_id,
|
|
83
|
-
changes,
|
|
84
|
-
});
|
|
85
72
|
}
|
|
86
73
|
}
|
|
87
74
|
exports.OrganizationPaymentMethodsResource = OrganizationPaymentMethodsResource;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CreateOrganizationDto as CreateOrganizationDtoType, UpdateOrganizationDto as UpdateOrganizationDtoType } from '@managemint-solutions/entities/organization/dto';
|
|
1
|
+
import type { CreateOrganizationDto as CreateOrganizationDtoType, GetOrganizationsDto as GetOrganizationsDtoType, OrganizationIdDto as OrganizationIdDtoType, UpdateOrganizationDto as UpdateOrganizationDtoType } from '@managemint-solutions/entities/organization/dto';
|
|
2
2
|
import { OrganizationTypes } from '@managemint-solutions/entities/organization/enum';
|
|
3
3
|
export declare class CreateOrganizationDto implements CreateOrganizationDtoType {
|
|
4
4
|
readonly org_type: OrganizationTypes;
|
|
@@ -61,3 +61,11 @@ export declare class UpdateOrganizationDto implements UpdateOrganizationDtoType
|
|
|
61
61
|
readonly currency?: string;
|
|
62
62
|
readonly leave_calendar_start_month?: number;
|
|
63
63
|
}
|
|
64
|
+
export declare class OrganizationIdDto implements OrganizationIdDtoType {
|
|
65
|
+
readonly organizationId: string;
|
|
66
|
+
}
|
|
67
|
+
export declare class GetOrganizationsDto implements GetOrganizationsDtoType {
|
|
68
|
+
readonly search?: string | null;
|
|
69
|
+
readonly page: number;
|
|
70
|
+
readonly page_size: number;
|
|
71
|
+
}
|
|
@@ -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.UpdateOrganizationDto = exports.CreateOrganizationDto = void 0;
|
|
12
|
+
exports.GetOrganizationsDto = exports.OrganizationIdDto = exports.UpdateOrganizationDto = exports.CreateOrganizationDto = void 0;
|
|
13
13
|
const enum_1 = require("@managemint-solutions/entities/organization/enum");
|
|
14
14
|
const class_validator_1 = require("class-validator");
|
|
15
15
|
const validators_1 = require("../validators");
|
|
@@ -450,3 +450,42 @@ __decorate([
|
|
|
450
450
|
(0, class_validator_1.Max)(12, { message: 'Leave year start month must be between 1 and 12' }),
|
|
451
451
|
__metadata("design:type", Number)
|
|
452
452
|
], UpdateOrganizationDto.prototype, "leave_calendar_start_month", void 0);
|
|
453
|
+
// No tenant route takes one - a tenant only ever reaches their own organization - so this is the
|
|
454
|
+
// admin api's `/organizations/:organizationId`.
|
|
455
|
+
class OrganizationIdDto {
|
|
456
|
+
organizationId;
|
|
457
|
+
}
|
|
458
|
+
exports.OrganizationIdDto = OrganizationIdDto;
|
|
459
|
+
__decorate([
|
|
460
|
+
(0, class_validator_1.IsUUID)('7', { message: 'Invalid organization ID format' }),
|
|
461
|
+
__metadata("design:type", String)
|
|
462
|
+
], OrganizationIdDto.prototype, "organizationId", void 0);
|
|
463
|
+
class GetOrganizationsDto {
|
|
464
|
+
search;
|
|
465
|
+
page = 1;
|
|
466
|
+
page_size = 10;
|
|
467
|
+
}
|
|
468
|
+
exports.GetOrganizationsDto = GetOrganizationsDto;
|
|
469
|
+
__decorate([
|
|
470
|
+
(0, class_validator_1.IsOptional)(),
|
|
471
|
+
(0, validators_1.IsNullable)(),
|
|
472
|
+
(0, class_validator_1.IsString)({ message: 'Search query must be a string' }),
|
|
473
|
+
(0, class_validator_1.MinLength)(1, { message: 'Search query cannot be empty' }),
|
|
474
|
+
(0, class_validator_1.MaxLength)(255, { message: 'Search query cannot exceed 255 characters' }),
|
|
475
|
+
__metadata("design:type", Object)
|
|
476
|
+
], GetOrganizationsDto.prototype, "search", void 0);
|
|
477
|
+
__decorate([
|
|
478
|
+
(0, class_validator_1.IsOptional)(),
|
|
479
|
+
(0, class_transformer_1.Transform)(({ value }) => value ? Number(value) : 1, { toClassOnly: true }),
|
|
480
|
+
(0, class_validator_1.IsNumber)({}, { message: 'Page must be a number' }),
|
|
481
|
+
(0, class_validator_1.Min)(1, { message: 'Page must be at least 1' }),
|
|
482
|
+
__metadata("design:type", Number)
|
|
483
|
+
], GetOrganizationsDto.prototype, "page", void 0);
|
|
484
|
+
__decorate([
|
|
485
|
+
(0, class_validator_1.IsOptional)(),
|
|
486
|
+
(0, class_transformer_1.Transform)(({ value }) => value ? Number(value) : 10, { toClassOnly: true }),
|
|
487
|
+
(0, class_validator_1.IsNumber)({}, { message: 'Page size must be a number' }),
|
|
488
|
+
(0, class_validator_1.Min)(1, { message: 'Page size must be at least 1' }),
|
|
489
|
+
(0, class_validator_1.Max)(100, { message: 'Page size cannot exceed 100' }),
|
|
490
|
+
__metadata("design:type", Number)
|
|
491
|
+
], GetOrganizationsDto.prototype, "page_size", void 0);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { SeatReservation } from '@managemint-solutions/entities/billing';
|
|
2
|
-
import
|
|
2
|
+
import type { MmsList } from '@managemint-solutions/entities/common';
|
|
3
|
+
import type { OrganizationSummary } from '@managemint-solutions/entities/organization';
|
|
4
|
+
import { type AuditResource, type AuditRow, EntityAuditTrail, type ResourceActor } from '../audit';
|
|
3
5
|
import type { TypedSupabaseClient } from '../client';
|
|
6
|
+
import type { GetOrganizationsDto } from './dto';
|
|
4
7
|
export * from './dto';
|
|
5
8
|
/** The organizations row as `ORGANIZATION_SELECT` produces it, before the api shapes it. */
|
|
6
9
|
export type OrganizationRow = Record<string, unknown> & {
|
|
@@ -18,10 +21,17 @@ export declare class OrganizationsResource {
|
|
|
18
21
|
private readonly supabase;
|
|
19
22
|
private readonly auth;
|
|
20
23
|
private readonly table;
|
|
21
|
-
|
|
24
|
+
/** This record's history - its own rows and the rows that hang off it. */
|
|
25
|
+
readonly audit: EntityAuditTrail;
|
|
26
|
+
constructor(supabase: TypedSupabaseClient, auth: ResourceActor, audit: AuditResource);
|
|
22
27
|
getById(organizationId: string): Promise<OrganizationRow>;
|
|
23
|
-
/** The caller's own organization, from the auth context. */
|
|
28
|
+
/** The caller's own organization, from the auth context. Staff have none: a 403. */
|
|
24
29
|
get(): Promise<OrganizationRow>;
|
|
30
|
+
/**
|
|
31
|
+
* Every organization the caller can read, newest first - all of them for staff, the caller's
|
|
32
|
+
* own for a tenant, as RLS decides. `search` matches the name, trading name and email.
|
|
33
|
+
*/
|
|
34
|
+
list(query: GetOrganizationsDto): Promise<MmsList<OrganizationSummary>>;
|
|
25
35
|
/**
|
|
26
36
|
* The whole row, unprojected. Billing reads a different handful of columns for each of its
|
|
27
37
|
* flows - provisioning, a module purchase, a seat charge - and they are all single-row
|
|
@@ -18,14 +18,37 @@ exports.OrganizationSignupResource = exports.OrganizationsResource = void 0;
|
|
|
18
18
|
const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
19
19
|
const audit_1 = require("../audit");
|
|
20
20
|
const errors_1 = require("../errors");
|
|
21
|
+
const tenancy_1 = require("../tenancy");
|
|
21
22
|
const transforms_1 = require("../transforms");
|
|
22
23
|
__exportStar(require("./dto"), exports);
|
|
24
|
+
// The subscription machinery on the row - what the billing flows and the Paystack webhooks
|
|
25
|
+
// move. Its history is the billing ledger (billing_transactions, invoices, billing_events);
|
|
26
|
+
// the organization's audit trail is its profile, so these are never recorded there.
|
|
27
|
+
const SUBSCRIPTION_COLUMNS = [
|
|
28
|
+
'billing_status',
|
|
29
|
+
'billing_status_updated_at',
|
|
30
|
+
'billing_cycle_locked',
|
|
31
|
+
'payment_frequency',
|
|
32
|
+
'payment_override',
|
|
33
|
+
'last_payment_date',
|
|
34
|
+
'next_payment_date',
|
|
35
|
+
'access_expires_at',
|
|
36
|
+
'active_seat_count',
|
|
37
|
+
'paid_seat_count',
|
|
38
|
+
'modules',
|
|
39
|
+
'pricebook_id',
|
|
40
|
+
'card_brand',
|
|
41
|
+
'card_description',
|
|
42
|
+
'card_expiry_date',
|
|
43
|
+
'card_expiring_soon',
|
|
44
|
+
];
|
|
23
45
|
// The organizations row *is* the organization: it has no organization_id column, hence
|
|
24
46
|
// `scopeByOrganization: false` wherever it is audited.
|
|
25
47
|
const ORGANIZATIONS_TABLE = {
|
|
26
48
|
table: 'organizations',
|
|
27
49
|
entityType: enum_1.AuditTrailEntityType.ORGANIZATION,
|
|
28
50
|
scopeByOrganization: false,
|
|
51
|
+
untracked: SUBSCRIPTION_COLUMNS,
|
|
29
52
|
};
|
|
30
53
|
// The api maps this row into OrganizationEntity: the module keys stored here are priced
|
|
31
54
|
// against the organization's pricebook, and the flat address columns are nested. That is
|
|
@@ -72,6 +95,8 @@ const ORGANIZATION_SELECT = `
|
|
|
72
95
|
card_expiring_soon,
|
|
73
96
|
pricebook_id
|
|
74
97
|
`;
|
|
98
|
+
// The public contract is the shared OrganizationSummary; `modules` stays the stored module keys.
|
|
99
|
+
const ORGANIZATION_LIST_SELECT = 'mms_id, created_at, name, trading_name, email, billing_status, active_seat_count, paid_seat_count, modules';
|
|
75
100
|
/**
|
|
76
101
|
* The caller's own organization. Every method takes the id explicitly rather than reading it
|
|
77
102
|
* off the auth context, because the signup and webhook paths operate on an organization that
|
|
@@ -81,10 +106,13 @@ class OrganizationsResource {
|
|
|
81
106
|
supabase;
|
|
82
107
|
auth;
|
|
83
108
|
table;
|
|
109
|
+
/** This record's history - its own rows and the rows that hang off it. */
|
|
110
|
+
audit;
|
|
84
111
|
constructor(supabase, auth, audit) {
|
|
85
112
|
this.supabase = supabase;
|
|
86
113
|
this.auth = auth;
|
|
87
114
|
this.table = (0, audit_1.auditedTable)(supabase, auth, audit, ORGANIZATIONS_TABLE);
|
|
115
|
+
this.audit = new audit_1.EntityAuditTrail(supabase, auth, enum_1.AuditTrailEntityType.ORGANIZATION);
|
|
88
116
|
}
|
|
89
117
|
async getById(organizationId) {
|
|
90
118
|
const { data, error } = await this.supabase
|
|
@@ -96,9 +124,34 @@ class OrganizationsResource {
|
|
|
96
124
|
throw (0, errors_1.mapPostgrestError)(error);
|
|
97
125
|
return data;
|
|
98
126
|
}
|
|
99
|
-
/** The caller's own organization, from the auth context. */
|
|
127
|
+
/** The caller's own organization, from the auth context. Staff have none: a 403. */
|
|
100
128
|
async get() {
|
|
101
|
-
return this.getById(this.auth
|
|
129
|
+
return this.getById((0, tenancy_1.requireOrganization)(this.auth));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Every organization the caller can read, newest first - all of them for staff, the caller's
|
|
133
|
+
* own for a tenant, as RLS decides. `search` matches the name, trading name and email.
|
|
134
|
+
*/
|
|
135
|
+
async list(query) {
|
|
136
|
+
const page = query.page;
|
|
137
|
+
const pageSize = query.page_size;
|
|
138
|
+
const offset = (page - 1) * pageSize;
|
|
139
|
+
const trimmedSearch = query.search?.trim();
|
|
140
|
+
// `order`/`range` return `this`, so the search can be reassigned onto the builder without a
|
|
141
|
+
// cast. Exact, not estimated, like invoices: there are tens of organizations, not tens of
|
|
142
|
+
// thousands, and the pager must not offer a page that is not there.
|
|
143
|
+
let organizationsQuery = this.supabase
|
|
144
|
+
.from('organizations')
|
|
145
|
+
.select(ORGANIZATION_LIST_SELECT, { count: 'exact' })
|
|
146
|
+
.order('created_at', { ascending: false })
|
|
147
|
+
.range(offset, offset + pageSize - 1);
|
|
148
|
+
if (trimmedSearch) {
|
|
149
|
+
organizationsQuery = organizationsQuery.or((0, transforms_1.orIlike)(['name', 'trading_name', 'email'], trimmedSearch));
|
|
150
|
+
}
|
|
151
|
+
const { data, error, count } = await organizationsQuery;
|
|
152
|
+
if (error)
|
|
153
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
154
|
+
return (0, transforms_1.toMmsList)(data, page, pageSize, count);
|
|
102
155
|
}
|
|
103
156
|
/**
|
|
104
157
|
* The whole row, unprojected. Billing reads a different handful of columns for each of its
|
|
@@ -203,11 +256,14 @@ class OrganizationSignupResource {
|
|
|
203
256
|
if (error)
|
|
204
257
|
throw (0, errors_1.mapPostgrestError)(error);
|
|
205
258
|
const row = data;
|
|
259
|
+
const changes = (0, audit_1.snapshotChanges)(row, 'new');
|
|
260
|
+
for (const column of SUBSCRIPTION_COLUMNS)
|
|
261
|
+
delete changes[column];
|
|
206
262
|
await this.auditFor(row.mms_id).record({
|
|
207
263
|
action: enum_1.AuditTrailAction.INSERT,
|
|
208
264
|
entityType: enum_1.AuditTrailEntityType.ORGANIZATION,
|
|
209
265
|
entityId: row.mms_id,
|
|
210
|
-
changes
|
|
266
|
+
changes,
|
|
211
267
|
});
|
|
212
268
|
return (0, audit_1.withoutInternalId)(row);
|
|
213
269
|
}
|
|
@@ -7,6 +7,12 @@ export type PermissionFlags = {
|
|
|
7
7
|
is_owner: boolean;
|
|
8
8
|
is_admin: boolean;
|
|
9
9
|
} & Record<string, boolean>;
|
|
10
|
+
/**
|
|
11
|
+
* A guard's flag is interpolated into a select string, so it is checked against the shape a
|
|
12
|
+
* column name can take first - a flag never comes from a request, only from a route's own
|
|
13
|
+
* decorator, so anything else is a bug rather than a bad request.
|
|
14
|
+
*/
|
|
15
|
+
export declare const checkPermissionFlag: (flag: string) => void;
|
|
10
16
|
/**
|
|
11
17
|
* The `permissions` table. Rows are created by the `on_auth_user_created` trigger and deleted
|
|
12
18
|
* with the user - INSERT and DELETE are `false` for `authenticated` - so this resource only
|
|
@@ -26,10 +32,6 @@ export declare class PermissionsResource {
|
|
|
26
32
|
* The one lookup `PermissionsGuard` makes per gated request: the flag the route asked for,
|
|
27
33
|
* plus the two that bypass it. Returns null when the caller has no permissions row, which the
|
|
28
34
|
* guard treats as a denial.
|
|
29
|
-
*
|
|
30
|
-
* `flag` is interpolated into the select string, so it is checked against the shape a column
|
|
31
|
-
* name can take first - a flag never comes from a request, only from a route's own decorator,
|
|
32
|
-
* so anything else is a bug rather than a bad request.
|
|
33
35
|
*/
|
|
34
36
|
flagsFor(userId: string, flag: string): Promise<PermissionFlags | null>;
|
|
35
37
|
/** Whether a user owns the organization - the guard behind "you cannot deactivate the owner". */
|
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.PermissionsResource = void 0;
|
|
17
|
+
exports.PermissionsResource = exports.checkPermissionFlag = void 0;
|
|
18
18
|
const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
19
19
|
const audit_1 = require("../audit");
|
|
20
20
|
const errors_1 = require("../errors");
|
|
@@ -22,7 +22,24 @@ __exportStar(require("./dto"), exports);
|
|
|
22
22
|
const PERMISSIONS_TABLE = {
|
|
23
23
|
table: 'permissions',
|
|
24
24
|
entityType: enum_1.AuditTrailEntityType.PERMISSION,
|
|
25
|
+
// A permissions change shows in the history of the user it belongs to.
|
|
26
|
+
related: { entityType: enum_1.AuditTrailEntityType.USER, column: 'user_id' },
|
|
25
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* A guard's flag is interpolated into a select string, so it is checked against the shape a
|
|
30
|
+
* column name can take first - a flag never comes from a request, only from a route's own
|
|
31
|
+
* decorator, so anything else is a bug rather than a bad request.
|
|
32
|
+
*/
|
|
33
|
+
const checkPermissionFlag = (flag) => {
|
|
34
|
+
if (!/^[a-z][a-z0-9_]*$/.test(flag)) {
|
|
35
|
+
throw new errors_1.SupabaseClientError({
|
|
36
|
+
status: 500,
|
|
37
|
+
error: 'Internal Error',
|
|
38
|
+
message: 'Invalid permission flag',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
exports.checkPermissionFlag = checkPermissionFlag;
|
|
26
43
|
/**
|
|
27
44
|
* The `permissions` table. Rows are created by the `on_auth_user_created` trigger and deleted
|
|
28
45
|
* with the user - INSERT and DELETE are `false` for `authenticated` - so this resource only
|
|
@@ -56,19 +73,9 @@ class PermissionsResource {
|
|
|
56
73
|
* The one lookup `PermissionsGuard` makes per gated request: the flag the route asked for,
|
|
57
74
|
* plus the two that bypass it. Returns null when the caller has no permissions row, which the
|
|
58
75
|
* guard treats as a denial.
|
|
59
|
-
*
|
|
60
|
-
* `flag` is interpolated into the select string, so it is checked against the shape a column
|
|
61
|
-
* name can take first - a flag never comes from a request, only from a route's own decorator,
|
|
62
|
-
* so anything else is a bug rather than a bad request.
|
|
63
76
|
*/
|
|
64
77
|
async flagsFor(userId, flag) {
|
|
65
|
-
|
|
66
|
-
throw new errors_1.SupabaseClientError({
|
|
67
|
-
status: 500,
|
|
68
|
-
error: 'Internal Error',
|
|
69
|
-
message: 'Invalid permission flag',
|
|
70
|
-
});
|
|
71
|
-
}
|
|
78
|
+
(0, exports.checkPermissionFlag)(flag);
|
|
72
79
|
const { data, error } = await this.supabase
|
|
73
80
|
.from('permissions')
|
|
74
81
|
.select(`is_owner, is_admin, ${flag}`)
|
package/dist/projects/dto.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CreateProjectDto as CreateProjectDtoType, GetProjectsDto as GetProjectsDtoType, ProjectIdDto as ProjectIdDtoType, UpdateProjectDto as UpdateProjectDtoType } from '@managemint-solutions/entities/projects/dto';
|
|
1
|
+
import type { CreateProjectDto as CreateProjectDtoType, GetProjectsDto as GetProjectsDtoType, StaffGetProjectsDto as StaffGetProjectsDtoType, ProjectIdDto as ProjectIdDtoType, UpdateProjectDto as UpdateProjectDtoType } from '@managemint-solutions/entities/projects/dto';
|
|
2
2
|
export declare class GetProjectsDto implements Omit<GetProjectsDtoType, 'status_id_in' | 'status_id_not_in'> {
|
|
3
3
|
readonly search?: string | null;
|
|
4
4
|
readonly page: number;
|
|
@@ -15,6 +15,10 @@ export declare class GetProjectsDto implements Omit<GetProjectsDtoType, 'status_
|
|
|
15
15
|
readonly client_id?: string | null;
|
|
16
16
|
readonly archive: boolean;
|
|
17
17
|
}
|
|
18
|
+
/** A staff read across organizations, optionally narrowed to one of them. */
|
|
19
|
+
export declare class StaffGetProjectsDto extends GetProjectsDto implements Omit<StaffGetProjectsDtoType, 'status_id_in' | 'status_id_not_in'> {
|
|
20
|
+
readonly organization_id?: string | null;
|
|
21
|
+
}
|
|
18
22
|
export declare class ProjectIdDto implements ProjectIdDtoType {
|
|
19
23
|
readonly projectId: string;
|
|
20
24
|
}
|
package/dist/projects/dto.js
CHANGED
|
@@ -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.UpdateProjectDto = exports.CreateProjectDto = exports.ProjectIdDto = exports.GetProjectsDto = void 0;
|
|
12
|
+
exports.UpdateProjectDto = exports.CreateProjectDto = exports.ProjectIdDto = exports.StaffGetProjectsDto = exports.GetProjectsDto = void 0;
|
|
13
13
|
const class_validator_1 = require("class-validator");
|
|
14
14
|
const class_transformer_1 = require("class-transformer");
|
|
15
15
|
const transforms_1 = require("../transforms");
|
|
@@ -132,6 +132,17 @@ __decorate([
|
|
|
132
132
|
(0, class_validator_1.IsBoolean)({ message: 'Include archived must be a boolean' }),
|
|
133
133
|
__metadata("design:type", Boolean)
|
|
134
134
|
], GetProjectsDto.prototype, "archive", void 0);
|
|
135
|
+
/** A staff read across organizations, optionally narrowed to one of them. */
|
|
136
|
+
class StaffGetProjectsDto extends GetProjectsDto {
|
|
137
|
+
organization_id;
|
|
138
|
+
}
|
|
139
|
+
exports.StaffGetProjectsDto = StaffGetProjectsDto;
|
|
140
|
+
__decorate([
|
|
141
|
+
(0, class_validator_1.IsOptional)(),
|
|
142
|
+
(0, validators_1.IsNullable)(),
|
|
143
|
+
(0, class_validator_1.IsUUID)('7', { message: 'Invalid organization ID format' }),
|
|
144
|
+
__metadata("design:type", Object)
|
|
145
|
+
], StaffGetProjectsDto.prototype, "organization_id", void 0);
|
|
135
146
|
class ProjectIdDto {
|
|
136
147
|
projectId;
|
|
137
148
|
}
|
package/dist/projects/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MmsList } from '@managemint-solutions/entities/common';
|
|
2
2
|
import type { ProjectEntity, ProjectSummary } from '@managemint-solutions/entities/projects';
|
|
3
|
-
import type {
|
|
4
|
-
import { type AuditResource } from '../audit';
|
|
3
|
+
import type { ClientContext } from '../auth';
|
|
4
|
+
import { EntityAuditTrail, type AuditResource } from '../audit';
|
|
5
5
|
import type { TypedSupabaseClient } from '../client';
|
|
6
6
|
import type { CreateProjectDto, GetProjectsDto, UpdateProjectDto } from './dto';
|
|
7
7
|
export * from './dto';
|
|
@@ -9,8 +9,12 @@ export declare class ProjectsResource {
|
|
|
9
9
|
private readonly supabase;
|
|
10
10
|
private readonly auth;
|
|
11
11
|
private readonly table;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** This record's history - its own rows and the rows that hang off it. */
|
|
13
|
+
readonly audit: EntityAuditTrail;
|
|
14
|
+
constructor(supabase: TypedSupabaseClient, auth: ClientContext, audit: AuditResource);
|
|
15
|
+
list(query: GetProjectsDto & {
|
|
16
|
+
organization_id?: string | null;
|
|
17
|
+
}): Promise<MmsList<ProjectSummary>>;
|
|
14
18
|
getById(projectId: string): Promise<ProjectEntity>;
|
|
15
19
|
create(input: CreateProjectDto): Promise<ProjectEntity>;
|
|
16
20
|
update(projectId: string, input: UpdateProjectDto): Promise<ProjectEntity>;
|
package/dist/projects/index.js
CHANGED
|
@@ -18,6 +18,7 @@ exports.ProjectsResource = void 0;
|
|
|
18
18
|
const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
19
19
|
const audit_1 = require("../audit");
|
|
20
20
|
const errors_1 = require("../errors");
|
|
21
|
+
const tenancy_1 = require("../tenancy");
|
|
21
22
|
const transforms_1 = require("../transforms");
|
|
22
23
|
__exportStar(require("./dto"), exports);
|
|
23
24
|
// The public contract is the shared ProjectSummary / ProjectEntity from
|
|
@@ -79,6 +80,8 @@ class ProjectsResource {
|
|
|
79
80
|
supabase;
|
|
80
81
|
auth;
|
|
81
82
|
table;
|
|
83
|
+
/** This record's history - its own rows and the rows that hang off it. */
|
|
84
|
+
audit;
|
|
82
85
|
constructor(supabase, auth, audit) {
|
|
83
86
|
this.supabase = supabase;
|
|
84
87
|
this.auth = auth;
|
|
@@ -86,6 +89,7 @@ class ProjectsResource {
|
|
|
86
89
|
table: 'projects',
|
|
87
90
|
entityType: enum_1.AuditTrailEntityType.PROJECTS,
|
|
88
91
|
});
|
|
92
|
+
this.audit = new audit_1.EntityAuditTrail(supabase, auth, enum_1.AuditTrailEntityType.PROJECTS);
|
|
89
93
|
}
|
|
90
94
|
async list(query) {
|
|
91
95
|
const page = query.page;
|
|
@@ -94,14 +98,16 @@ class ProjectsResource {
|
|
|
94
98
|
const trimmedSearch = query.search?.trim();
|
|
95
99
|
// `order`/`range` return `this`, so the builder stays a filter builder and the conditional
|
|
96
100
|
// filters below can be reassigned onto it without a cast.
|
|
97
|
-
let projectsQuery = this.supabase
|
|
101
|
+
let projectsQuery = (0, tenancy_1.scopeToOrganization)(this.supabase
|
|
98
102
|
.from('projects')
|
|
99
|
-
.select(PROJECT_LIST_SELECT, { count: 'estimated' })
|
|
100
|
-
.eq('organization_id', this.auth.organization_id)
|
|
103
|
+
.select((0, tenancy_1.withOrganization)(PROJECT_LIST_SELECT, this.auth), { count: 'estimated' }), this.auth)
|
|
101
104
|
.eq('archived', query.archive)
|
|
102
105
|
.eq('deleted', false)
|
|
103
106
|
.range(offset, offset + pageSize - 1)
|
|
104
107
|
.order('created_at', { ascending: false });
|
|
108
|
+
if (query.organization_id) {
|
|
109
|
+
projectsQuery = projectsQuery.eq('organization_id', query.organization_id);
|
|
110
|
+
}
|
|
105
111
|
if (trimmedSearch) {
|
|
106
112
|
projectsQuery = projectsQuery.or((0, transforms_1.orIlike)(['name', 'specification'], trimmedSearch));
|
|
107
113
|
}
|
|
@@ -144,10 +150,7 @@ class ProjectsResource {
|
|
|
144
150
|
return (0, transforms_1.toMmsList)(data, page, pageSize, count);
|
|
145
151
|
}
|
|
146
152
|
async getById(projectId) {
|
|
147
|
-
const { data, error } = await this.supabase
|
|
148
|
-
.from('projects')
|
|
149
|
-
.select(PROJECT_DETAIL_SELECT)
|
|
150
|
-
.eq('organization_id', this.auth.organization_id)
|
|
153
|
+
const { data, error } = await (0, tenancy_1.scopeToOrganization)(this.supabase.from('projects').select(PROJECT_DETAIL_SELECT), this.auth)
|
|
151
154
|
.eq('mms_id', projectId)
|
|
152
155
|
.single();
|
|
153
156
|
if (error)
|
|
@@ -179,7 +182,7 @@ class ProjectsResource {
|
|
|
179
182
|
...input,
|
|
180
183
|
start_date: (0, transforms_1.toDateInput)(input.start_date),
|
|
181
184
|
end_date: (0, transforms_1.toDateInput)(input.end_date),
|
|
182
|
-
organization_id: this.auth
|
|
185
|
+
organization_id: (0, tenancy_1.requireOrganization)(this.auth),
|
|
183
186
|
created_by: this.auth.mms_id,
|
|
184
187
|
created_at: new Date().toISOString(),
|
|
185
188
|
});
|