@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.
Files changed (72) hide show
  1. package/README.md +92 -13
  2. package/dist/additional-details/index.d.ts +2 -2
  3. package/dist/additional-details/index.js +6 -10
  4. package/dist/audit/dto.d.ts +3 -8
  5. package/dist/audit/dto.js +7 -48
  6. package/dist/audit/index.d.ts +71 -10
  7. package/dist/audit/index.js +234 -55
  8. package/dist/auth-client/index.d.ts +2 -0
  9. package/dist/auth-client/index.js +12 -0
  10. package/dist/auth.d.ts +21 -0
  11. package/dist/auth.js +47 -3
  12. package/dist/clients/dto.d.ts +5 -1
  13. package/dist/clients/dto.js +12 -1
  14. package/dist/clients/index.d.ts +8 -4
  15. package/dist/clients/index.js +11 -8
  16. package/dist/emulation-handshakes/index.d.ts +13 -0
  17. package/dist/emulation-handshakes/index.js +36 -0
  18. package/dist/errors.js +2 -2
  19. package/dist/index.d.ts +14 -10
  20. package/dist/index.js +23 -4
  21. package/dist/leads/dto.d.ts +5 -1
  22. package/dist/leads/dto.js +12 -1
  23. package/dist/leads/index.d.ts +8 -5
  24. package/dist/leads/index.js +23 -21
  25. package/dist/leave/leave-adjustments.js +1 -0
  26. package/dist/leave/leave-entitlements.d.ts +3 -1
  27. package/dist/leave/leave-entitlements.js +3 -0
  28. package/dist/leave/leave-requests.d.ts +3 -1
  29. package/dist/leave/leave-requests.js +3 -0
  30. package/dist/nest/index.d.ts +26 -1
  31. package/dist/nest/index.js +58 -1
  32. package/dist/notifications/index.d.ts +1 -3
  33. package/dist/organization-payment-methods/index.d.ts +5 -6
  34. package/dist/organization-payment-methods/index.js +4 -17
  35. package/dist/organizations/dto.d.ts +9 -1
  36. package/dist/organizations/dto.js +40 -1
  37. package/dist/organizations/index.d.ts +13 -3
  38. package/dist/organizations/index.js +59 -3
  39. package/dist/permissions/index.d.ts +6 -4
  40. package/dist/permissions/index.js +19 -12
  41. package/dist/projects/dto.d.ts +5 -1
  42. package/dist/projects/dto.js +12 -1
  43. package/dist/projects/index.d.ts +8 -4
  44. package/dist/projects/index.js +11 -8
  45. package/dist/service-client/index.d.ts +39 -1
  46. package/dist/service-client/index.js +89 -2
  47. package/dist/staff/dto.d.ts +14 -0
  48. package/dist/staff/dto.js +63 -0
  49. package/dist/staff/index.d.ts +50 -0
  50. package/dist/staff/index.js +139 -0
  51. package/dist/staff-client.d.ts +37 -0
  52. package/dist/staff-client.js +52 -0
  53. package/dist/tasks/dto.d.ts +5 -1
  54. package/dist/tasks/dto.js +12 -1
  55. package/dist/tasks/index.d.ts +15 -10
  56. package/dist/tasks/index.js +34 -31
  57. package/dist/tenancy.d.ts +13 -0
  58. package/dist/tenancy.js +25 -0
  59. package/dist/timesheets/timesheet-entries.js +1 -0
  60. package/dist/timesheets/timesheets.d.ts +3 -1
  61. package/dist/timesheets/timesheets.js +3 -0
  62. package/dist/user-api-credentials/dto.d.ts +12 -0
  63. package/dist/user-api-credentials/dto.js +56 -0
  64. package/dist/user-api-credentials/index.d.ts +47 -0
  65. package/dist/user-api-credentials/index.js +101 -0
  66. package/dist/users/dto.d.ts +5 -1
  67. package/dist/users/dto.js +13 -2
  68. package/dist/users/index.d.ts +7 -3
  69. package/dist/users/index.js +16 -29
  70. package/dist/validators.d.ts +2 -0
  71. package/dist/validators.js +6 -1
  72. package/package.json +2 -2
@@ -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()`.
@@ -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 AuditedTable, type AuditResource } from '../audit';
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 { type AuditActor, type AuditResource } from '../audit';
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 against the organization (the row
35
- * has no identity of its own), and the audit diff redacts every `paystack_*` column, so the trail
36
- * records that a value changed and never what it changed to.
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
- private readonly audit;
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 against the organization (the row
37
- * has no identity of its own), and the audit diff redacts every `paystack_*` column, so the trail
38
- * records that a value changed and never what it changed to.
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
- audit;
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 { type AuditActor, type AuditResource, type AuditRow } from '../audit';
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
- constructor(supabase: TypedSupabaseClient, auth: AuditActor, audit: AuditResource);
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.organization_id);
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: (0, audit_1.snapshotChanges)(row, 'new'),
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
- if (!/^[a-z][a-z0-9_]*$/.test(flag)) {
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}`)
@@ -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
  }
@@ -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
  }
@@ -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 { AuthContext } from '../auth';
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
- constructor(supabase: TypedSupabaseClient, auth: AuthContext, audit: AuditResource);
13
- list(query: GetProjectsDto): Promise<MmsList<ProjectSummary>>;
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>;
@@ -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.organization_id,
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
  });