@astram/identity 0.1.2 → 0.2.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/dist/auth-facts-keys.d.ts +3 -0
- package/dist/auth-facts-keys.d.ts.map +1 -0
- package/dist/auth-facts-keys.js +24 -0
- package/dist/cross-tenant-grant.object.d.ts +28 -0
- package/dist/cross-tenant-grant.object.d.ts.map +1 -0
- package/dist/cross-tenant-grant.object.js +64 -0
- package/dist/features/access/access-admin.access.d.ts +3 -0
- package/dist/features/access/access-admin.access.d.ts.map +1 -0
- package/dist/features/access/access-admin.access.js +27 -0
- package/dist/features/access/company-admin.access.d.ts +3 -0
- package/dist/features/access/company-admin.access.d.ts.map +1 -0
- package/dist/features/access/company-admin.access.js +31 -0
- package/dist/features/access/index.d.ts +15 -0
- package/dist/features/access/index.d.ts.map +1 -0
- package/dist/features/access/index.js +26 -0
- package/dist/features/access/user-management.access.d.ts +3 -0
- package/dist/features/access/user-management.access.d.ts.map +1 -0
- package/dist/features/access/user-management.access.js +35 -0
- package/dist/features/audit/audit-read.service.d.ts +92 -0
- package/dist/features/audit/audit-read.service.d.ts.map +1 -0
- package/dist/features/audit/audit-read.service.js +97 -0
- package/dist/features/audit/audit.controller.d.ts +35 -0
- package/dist/features/audit/audit.controller.d.ts.map +1 -0
- package/dist/features/audit/audit.controller.js +73 -0
- package/dist/features/audit/directory-label-resolver.d.ts +14 -0
- package/dist/features/audit/directory-label-resolver.d.ts.map +1 -0
- package/dist/features/audit/directory-label-resolver.js +46 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts +46 -0
- package/dist/features/audit/mariadb-audit-reader.d.ts.map +1 -0
- package/dist/features/audit/mariadb-audit-reader.js +116 -0
- package/dist/features/authz/access-bundle-reconcile.job.d.ts +35 -0
- package/dist/features/authz/access-bundle-reconcile.job.d.ts.map +1 -0
- package/dist/features/authz/access-bundle-reconcile.job.js +84 -0
- package/dist/features/authz/access-bundle-store.d.ts +65 -0
- package/dist/features/authz/access-bundle-store.d.ts.map +1 -0
- package/dist/features/authz/access-bundle-store.js +154 -0
- package/dist/features/authz/catalog-bootstrap.provider.d.ts +28 -0
- package/dist/features/authz/catalog-bootstrap.provider.d.ts.map +1 -0
- package/dist/features/authz/catalog-bootstrap.provider.js +85 -0
- package/dist/features/authz/catalog-reconcile.job.d.ts +23 -0
- package/dist/features/authz/catalog-reconcile.job.d.ts.map +1 -0
- package/dist/features/authz/catalog-reconcile.job.js +47 -0
- package/dist/features/authz/db-permission-facts.adapter.d.ts +60 -0
- package/dist/features/authz/db-permission-facts.adapter.d.ts.map +1 -0
- package/dist/features/authz/db-permission-facts.adapter.js +116 -0
- package/dist/features/authz/grant-admin.controller.d.ts +96 -0
- package/dist/features/authz/grant-admin.controller.d.ts.map +1 -0
- package/dist/features/authz/grant-admin.controller.js +209 -0
- package/dist/features/authz/grant-admin.service.d.ts +107 -0
- package/dist/features/authz/grant-admin.service.d.ts.map +1 -0
- package/dist/features/authz/grant-admin.service.js +297 -0
- package/dist/features/authz/mariadb-catalog-store.d.ts +16 -0
- package/dist/features/authz/mariadb-catalog-store.d.ts.map +1 -0
- package/dist/features/authz/mariadb-catalog-store.js +44 -0
- package/dist/features/authz/permission-version.bumper.d.ts +47 -0
- package/dist/features/authz/permission-version.bumper.d.ts.map +1 -0
- package/dist/features/authz/permission-version.bumper.js +68 -0
- package/dist/features/authz/record-authorization.d.ts +31 -0
- package/dist/features/authz/record-authorization.d.ts.map +1 -0
- package/dist/features/authz/record-authorization.js +59 -0
- package/dist/features/authz/session-facts.reader.d.ts +41 -0
- package/dist/features/authz/session-facts.reader.d.ts.map +1 -0
- package/dist/features/authz/session-facts.reader.js +27 -0
- package/dist/features/company/company.controller.d.ts +37 -0
- package/dist/features/company/company.controller.d.ts.map +1 -0
- package/dist/features/company/company.controller.js +100 -0
- package/dist/features/company/company.service.d.ts +25 -0
- package/dist/features/company/company.service.d.ts.map +1 -0
- package/dist/features/company/company.service.js +53 -0
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.d.ts +126 -0
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.d.ts.map +1 -0
- package/dist/features/cross-tenant/cross-tenant-grant-read.service.js +276 -0
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts +65 -0
- package/dist/features/cross-tenant/cross-tenant-grant.controller.d.ts.map +1 -0
- package/dist/features/cross-tenant/cross-tenant-grant.controller.js +250 -0
- package/dist/features/cross-tenant/grant-authorize.d.ts +42 -0
- package/dist/features/cross-tenant/grant-authorize.d.ts.map +1 -0
- package/dist/features/cross-tenant/grant-authorize.js +17 -0
- package/dist/features/identity-feature.module.d.ts +26 -0
- package/dist/features/identity-feature.module.d.ts.map +1 -0
- package/dist/features/identity-feature.module.js +247 -0
- package/dist/features/membership/membership.controller.d.ts +77 -0
- package/dist/features/membership/membership.controller.d.ts.map +1 -0
- package/dist/features/membership/membership.controller.js +174 -0
- package/dist/features/membership/membership.service.d.ts +73 -0
- package/dist/features/membership/membership.service.d.ts.map +1 -0
- package/dist/features/membership/membership.service.js +173 -0
- package/dist/features/membership/posting-version-bump.d.ts +10 -0
- package/dist/features/membership/posting-version-bump.d.ts.map +1 -0
- package/dist/features/membership/posting-version-bump.js +4 -0
- package/dist/features/membership/posting.service.d.ts +59 -0
- package/dist/features/membership/posting.service.d.ts.map +1 -0
- package/dist/features/membership/posting.service.js +181 -0
- package/dist/features/org-structure/org-catalog.controller.d.ts +14 -0
- package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -0
- package/dist/features/org-structure/org-catalog.controller.js +62 -0
- package/dist/features/org-structure/org-catalog.service.d.ts +19 -0
- package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -0
- package/dist/features/org-structure/org-catalog.service.js +53 -0
- package/dist/features/role/role.controller.d.ts +37 -0
- package/dist/features/role/role.controller.d.ts.map +1 -0
- package/dist/features/role/role.controller.js +104 -0
- package/dist/features/role/role.service.d.ts +24 -0
- package/dist/features/role/role.service.d.ts.map +1 -0
- package/dist/features/role/role.service.js +53 -0
- package/dist/features/shared/http.d.ts +15 -0
- package/dist/features/shared/http.d.ts.map +1 -0
- package/dist/features/shared/http.js +25 -0
- package/dist/features/shared/list-query.d.ts +44 -0
- package/dist/features/shared/list-query.d.ts.map +1 -0
- package/dist/features/shared/list-query.js +53 -0
- package/dist/features/shared/named-option.d.ts +6 -0
- package/dist/features/shared/named-option.d.ts.map +1 -0
- package/dist/features/shared/named-option.js +2 -0
- package/dist/features/shared/record-resolver.d.ts +11 -0
- package/dist/features/shared/record-resolver.d.ts.map +1 -0
- package/dist/features/shared/record-resolver.js +32 -0
- package/dist/features/user/user.controller.d.ts +39 -0
- package/dist/features/user/user.controller.d.ts.map +1 -0
- package/dist/features/user/user.controller.js +104 -0
- package/dist/features/user/user.service.d.ts +26 -0
- package/dist/features/user/user.service.d.ts.map +1 -0
- package/dist/features/user/user.service.js +53 -0
- package/dist/index.d.ts +34 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +66 -33
- package/dist/objects.d.ts +13 -0
- package/dist/objects.d.ts.map +1 -0
- package/dist/objects.js +20 -0
- package/dist/shared-resource.object.d.ts +28 -0
- package/dist/shared-resource.object.d.ts.map +1 -0
- package/dist/shared-resource.object.js +63 -0
- package/migrations/1710000000010-CreateAuditLog.cjs +42 -0
- package/migrations/1710000000011-CreateCrossTenantGrant.cjs +66 -0
- package/migrations/1710000000012-SeedCrossTenantGrantPerms.cjs +86 -0
- package/migrations/1710000000013-CreateSharedResource.cjs +30 -0
- package/migrations/1710000000099-SeedDevIdentity.cjs +130 -0
- package/migrations/1710000000100-SeedDevTenantBAndRoot.cjs +212 -0
- package/package.json +4 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MembershipService — the membership layer's write/read logic, built ENTIRELY on the
|
|
4
|
+
* injected RecordsService facade (which supplies the request principal and runs each op
|
|
5
|
+
* through the engine pipe). It never touches a pool, query runner, or raw SQL — every op
|
|
6
|
+
* is one-or-more facade calls, so the permission gate + anti-forgery write-context +
|
|
7
|
+
* audit + cross-tenant isolation are applied automatically.
|
|
8
|
+
*
|
|
9
|
+
* A `company_membership` is the first-class `(user, company)` scope with `is_active`
|
|
10
|
+
* (exactly one active per user) + `status`. This service owns the ONE invariant the DB
|
|
11
|
+
* cannot express — one-active-per-user — because there is no partial-unique on
|
|
12
|
+
* `cm_is_active`; `setActive` is the guard.
|
|
13
|
+
*/
|
|
14
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
15
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
16
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
17
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
18
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
19
|
+
};
|
|
20
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
21
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
22
|
+
};
|
|
23
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
24
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.MembershipService = void 0;
|
|
28
|
+
const common_1 = require("@nestjs/common");
|
|
29
|
+
const engine_1 = require("@astram/engine");
|
|
30
|
+
const posting_version_bump_js_1 = require("./posting-version-bump.js");
|
|
31
|
+
let MembershipService = class MembershipService {
|
|
32
|
+
records;
|
|
33
|
+
versionBump;
|
|
34
|
+
constructor(records, versionBump) {
|
|
35
|
+
this.records = records;
|
|
36
|
+
this.versionBump = versionBump;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read a membership's tenant (its `companyUuid`) through the pipe, or `null` when
|
|
40
|
+
* unresolvable (→ no bump; fail-safe, never guesses a tenant). Used to target the
|
|
41
|
+
* permission-version bump at the company whose coarse grants a mutation changed.
|
|
42
|
+
*/
|
|
43
|
+
async tenantOf(membershipUuid) {
|
|
44
|
+
try {
|
|
45
|
+
const m = await this.records.get('company_membership', membershipUuid);
|
|
46
|
+
const companyUuid = m?.fields?.companyUuid;
|
|
47
|
+
return typeof companyUuid === 'string' && companyUuid.length > 0 ? companyUuid : null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Bump the permission version for each DISTINCT tenant in `tenantUuids` (best-effort +
|
|
55
|
+
* fail-safe): the mutation already succeeded, so a bump hiccup must NOT surface as an
|
|
56
|
+
* error that hides a successful membership change — the cache is invalidated by the next
|
|
57
|
+
* legit bump / the facts' natural TTL instead. No collaborator wired → no-op (prior
|
|
58
|
+
* behavior). `null`/empty entries are skipped (never bump a guessed tenant).
|
|
59
|
+
*/
|
|
60
|
+
async bumpTenants(tenantUuids) {
|
|
61
|
+
const bump = this.versionBump;
|
|
62
|
+
if (!bump)
|
|
63
|
+
return;
|
|
64
|
+
for (const tenant of new Set(tenantUuids.filter((t) => !!t))) {
|
|
65
|
+
try {
|
|
66
|
+
await bump.bumpTenant(tenant);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Fail-safe: never fail an applied membership change on a version-bump hiccup.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Add a membership card for `(userUuid, companyUuid)`. New memberships are created
|
|
75
|
+
* INACTIVE (`isActive:false`) — activation is an explicit `setActive` call so the
|
|
76
|
+
* one-active invariant is never violated by a fresh add. A duplicate `(user, company)`
|
|
77
|
+
* hits the DB's UNIQUE constraint and the engine's error surfaces to the caller (NOT
|
|
78
|
+
* swallowed here). `createdBy` is stamped server-side (actorId-trusted).
|
|
79
|
+
*/
|
|
80
|
+
addMembership(userUuid, companyUuid) {
|
|
81
|
+
return this.records.create('company_membership', {
|
|
82
|
+
fields: { userUuid, companyUuid, isActive: false, status: 'active' },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** List a user's membership cards (tenant-scoped by the pipe). */
|
|
86
|
+
async listMemberships(userUuid) {
|
|
87
|
+
const page = await this.records.list('company_membership', { where: { userUuid } });
|
|
88
|
+
return page.rows;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Flip which of the user's memberships is active — the ONE-ACTIVE invariant. The service
|
|
92
|
+
* is the guard because the DB has no partial-unique on `cm_is_active`.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ ORDER IS LOAD-BEARING (fail-safe against a LOGIN LOCKOUT). The engine's `list` has
|
|
95
|
+
* no ORDER BY (PKs are random uuids → arbitrary row order) and each `update` is its OWN
|
|
96
|
+
* transaction (no cross-call atomicity). So this MUST activate the TARGET FIRST, then
|
|
97
|
+
* deactivate the others:
|
|
98
|
+
* - Worst case of a mid-loop failure is then a transient TWO-active window, which is
|
|
99
|
+
* RECOVERABLE — the credential resolver reads `is_active=1 LIMIT 1`, so it still
|
|
100
|
+
* resolves a tenant and login works.
|
|
101
|
+
* - The forbidden alternative (deactivate-old-then-activate-target) can leave a
|
|
102
|
+
* ZERO-active window on a mid-failure → the resolver returns null → the user is
|
|
103
|
+
* LOCKED OUT of login until an admin re-activates. Never do that.
|
|
104
|
+
*
|
|
105
|
+
* Guard: if `membershipUuid` is NOT one of the user's memberships, throw — otherwise the
|
|
106
|
+
* loop would deactivate everything and activate nothing (also a zero-active lockout).
|
|
107
|
+
*/
|
|
108
|
+
async setActive(membershipUuid, userUuid) {
|
|
109
|
+
const page = await this.records.list('company_membership', { where: { userUuid } });
|
|
110
|
+
const target = page.rows.find((m) => m.id === membershipUuid);
|
|
111
|
+
if (!target) {
|
|
112
|
+
// Not this user's membership → refuse rather than leave the user with ZERO active
|
|
113
|
+
// (a login lockout). Fail-closed.
|
|
114
|
+
throw new common_1.NotFoundException(`membership ${membershipUuid} does not belong to user ${userUuid}`);
|
|
115
|
+
}
|
|
116
|
+
// 1) TARGET FIRST — guarantees an active membership exists before any deactivation, so
|
|
117
|
+
// a mid-failure below leaves TWO active (recoverable), never ZERO (lockout).
|
|
118
|
+
await this.records.update('company_membership', target.id, { fields: { isActive: true } });
|
|
119
|
+
// 2) Deactivate every OTHER membership (skip the target we just activated).
|
|
120
|
+
const deactivated = [];
|
|
121
|
+
for (const m of page.rows) {
|
|
122
|
+
if (m.id === membershipUuid)
|
|
123
|
+
continue;
|
|
124
|
+
await this.records.update('company_membership', m.id, { fields: { isActive: false } });
|
|
125
|
+
deactivated.push(m);
|
|
126
|
+
}
|
|
127
|
+
// ADR-0011 STALENESS FIX: activation ADDS the target company's role grants and each
|
|
128
|
+
// deactivation REMOVES that company's grants — a coarse-grant change in BOTH directions.
|
|
129
|
+
// A user's memberships can span DIFFERENT companies, so setActive may affect MULTIPLE
|
|
130
|
+
// tenants: bump the target's tenant AND every deactivated membership's tenant. Read each
|
|
131
|
+
// tenant from the already-loaded rows (companyUuid is a field on the membership) — no
|
|
132
|
+
// extra pipe reads. bumpTenants de-dupes + is best-effort/fail-safe (the flip already
|
|
133
|
+
// succeeded; a bump hiccup must not error).
|
|
134
|
+
const asCompanyUuid = (m) => {
|
|
135
|
+
const c = m.fields?.companyUuid;
|
|
136
|
+
return typeof c === 'string' && c.length > 0 ? c : null;
|
|
137
|
+
};
|
|
138
|
+
await this.bumpTenants([asCompanyUuid(target), ...deactivated.map(asCompanyUuid)]);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Update a membership's mutable non-activity fields (currently `status`). Activity is
|
|
142
|
+
* flipped only via `setActive` to preserve the one-active invariant, so `isActive` is
|
|
143
|
+
* intentionally NOT a patch key here.
|
|
144
|
+
*/
|
|
145
|
+
updateMembership(membershipUuid, patch) {
|
|
146
|
+
// NO version bump here (ADR-0011): this patches `status` ONLY — `isActive` is
|
|
147
|
+
// deliberately not a patch key (activity flips solely via `setActive`, which bumps),
|
|
148
|
+
// and the controller only ever passes `{ status }`. Coarse-grant resolution gates on
|
|
149
|
+
// `cm_is_active = 1`, NOT on `cm_status`, so a status change does NOT alter which roles
|
|
150
|
+
// resolve → the warm cache stays correct → no invalidation needed. If a future patch key
|
|
151
|
+
// is ever added here that CAN change which roles/postings resolve, it MUST bump the
|
|
152
|
+
// membership's tenant (mirror `setActive`).
|
|
153
|
+
return this.records.update('company_membership', membershipUuid, { fields: patch });
|
|
154
|
+
}
|
|
155
|
+
/** Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). */
|
|
156
|
+
async removeMembership(membershipUuid) {
|
|
157
|
+
// ADR-0011 STALENESS FIX: resolve the membership's tenant BEFORE the delete (it's
|
|
158
|
+
// unreadable afterwards). If the membership was active, deleting it (postings cascade)
|
|
159
|
+
// removes the user's role grants in that company — a coarse-grant change — so bump that
|
|
160
|
+
// tenant AFTER the delete succeeds. Best-effort/fail-safe (bumpTenants swallows). A
|
|
161
|
+
// membership belongs to exactly one company → one tenant bump.
|
|
162
|
+
const tenant = await this.tenantOf(membershipUuid);
|
|
163
|
+
await this.records.delete('company_membership', membershipUuid);
|
|
164
|
+
await this.bumpTenants([tenant]);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
exports.MembershipService = MembershipService;
|
|
168
|
+
exports.MembershipService = MembershipService = __decorate([
|
|
169
|
+
(0, common_1.Injectable)(),
|
|
170
|
+
__param(1, (0, common_1.Optional)()),
|
|
171
|
+
__param(1, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
|
|
172
|
+
__metadata("design:paramtypes", [engine_1.RecordsService, Object])
|
|
173
|
+
], MembershipService);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostingVersionBump — the seam the membership/posting services call after a change to bump the
|
|
3
|
+
* tenant's permission version (ADR-0011), so a role gain/loss lands in authorization WITHOUT
|
|
4
|
+
* re-login. Injected @Optional() — absent (unit tests) = no bump = prior behavior.
|
|
5
|
+
*/
|
|
6
|
+
export interface PostingVersionBump {
|
|
7
|
+
bumpTenant(tenantUuid: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare const POSTING_VERSION_BUMP: unique symbol;
|
|
10
|
+
//# sourceMappingURL=posting-version-bump.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"posting-version-bump.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting-version-bump.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,eAAO,MAAM,oBAAoB,eAAiC,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostingService — the ATOMIC replace-set of a membership's postings (Phase 3, Task 4),
|
|
3
|
+
* built on the injected RecordsService facade (→ the engine pipe). A posting is a bundled, time-bounded assignment:
|
|
4
|
+
* a role (+ optional department + optional branch) with an optional effective window.
|
|
5
|
+
* The Assignments editor submits the FULL desired row set for a membership; this service
|
|
6
|
+
* diffs current-vs-desired and applies the minimal change — delete only what's removed,
|
|
7
|
+
* create only what's added, keep the rest untouched.
|
|
8
|
+
*
|
|
9
|
+
* ── Atomicity / mid-op safety (why delete-then-create is safe here) ──────────────────
|
|
10
|
+
* The engine's feature-facing surface (`RecordsService`) exposes NO cross-call transaction
|
|
11
|
+
* — each of its methods (create/update/delete/list/get) runs in its OWN transaction, and
|
|
12
|
+
* no `externalQueryRunner` seam is handed to features. So `replacePostings` cannot wrap
|
|
13
|
+
* the whole diff in a single DB transaction. Instead it is safe BY CONSTRUCTION:
|
|
14
|
+
* - Deletes target ONLY rows absent from `desired` (the "removed" set). A KEPT posting
|
|
15
|
+
* is never a delete target, so it is never removed.
|
|
16
|
+
* - Creates target ONLY rows absent from `current` (the "added" set).
|
|
17
|
+
* Therefore the membership NEVER lands empty mid-op: even if a create fails after the
|
|
18
|
+
* delete phase, every kept posting is still present (never deleted). Ordering is
|
|
19
|
+
* delete-first, then create; a failure surfaces to the caller (fail-closed) with the
|
|
20
|
+
* kept set intact. (Covered by the mid-op-safety tests in posting.service.spec.ts.)
|
|
21
|
+
*/
|
|
22
|
+
import { RecordsService, type RecordView } from '@astram/engine';
|
|
23
|
+
import { type PostingVersionBump } from './posting-version-bump.js';
|
|
24
|
+
/**
|
|
25
|
+
* The shape of one desired posting row submitted by the editor. The effective-window
|
|
26
|
+
* bounds arrive from the client as a bare `YYYY-MM-DD` date string (native `<input
|
|
27
|
+
* type=date>`), an ISO datetime, an epoch-ms number, or `null` (open-ended). They are
|
|
28
|
+
* normalized to epoch-ms integers before the engine writes them (see `toEpochMs`).
|
|
29
|
+
*/
|
|
30
|
+
export interface PostingInput {
|
|
31
|
+
roleUuid: string;
|
|
32
|
+
departmentUuid?: string | null;
|
|
33
|
+
branchUuid?: string | null;
|
|
34
|
+
effectiveFrom?: string | number | null;
|
|
35
|
+
effectiveUntil?: string | number | null;
|
|
36
|
+
}
|
|
37
|
+
export declare class PostingService {
|
|
38
|
+
private readonly records;
|
|
39
|
+
private readonly versionBump?;
|
|
40
|
+
constructor(records: RecordsService, versionBump?: (PostingVersionBump | null) | undefined);
|
|
41
|
+
/** List a membership's postings (tenant-scoped by the pipe). */
|
|
42
|
+
listPostings(membershipUuid: string): Promise<readonly RecordView[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Replace a membership's full posting set with `desired`, applying the minimal diff.
|
|
45
|
+
* Compares current vs desired by the natural key `roleUuid|departmentUuid|branchUuid`:
|
|
46
|
+
* deletes removed rows, creates added rows, leaves kept rows untouched. See the file
|
|
47
|
+
* header for the atomicity / mid-op-safety reasoning (delete-then-create is safe because
|
|
48
|
+
* kept postings are never delete targets, so the membership never lands empty).
|
|
49
|
+
*/
|
|
50
|
+
replacePostings(membershipUuid: string, desired: readonly PostingInput[]): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the tenant (company uuid) a membership belongs to — the tenant whose permission
|
|
53
|
+
* version a posting change must bump. Reads the parent `company_membership` through the
|
|
54
|
+
* pipe (tenant-scoped, no raw SQL), returning `companyUuid` or `null` if unresolvable
|
|
55
|
+
* (then no bump — fail-safe; never guesses a tenant).
|
|
56
|
+
*/
|
|
57
|
+
private resolveMembershipTenant;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=posting.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AA4DD,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBALZ,OAAO,EAAE,cAAc,EAKvB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;;OAMG;IACG,eAAe,CAAC,cAAc,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IA4D9F;;;;;OAKG;YACW,uBAAuB;CAStC"}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PostingService — the ATOMIC replace-set of a membership's postings (Phase 3, Task 4),
|
|
4
|
+
* built on the injected RecordsService facade (→ the engine pipe). A posting is a bundled, time-bounded assignment:
|
|
5
|
+
* a role (+ optional department + optional branch) with an optional effective window.
|
|
6
|
+
* The Assignments editor submits the FULL desired row set for a membership; this service
|
|
7
|
+
* diffs current-vs-desired and applies the minimal change — delete only what's removed,
|
|
8
|
+
* create only what's added, keep the rest untouched.
|
|
9
|
+
*
|
|
10
|
+
* ── Atomicity / mid-op safety (why delete-then-create is safe here) ──────────────────
|
|
11
|
+
* The engine's feature-facing surface (`RecordsService`) exposes NO cross-call transaction
|
|
12
|
+
* — each of its methods (create/update/delete/list/get) runs in its OWN transaction, and
|
|
13
|
+
* no `externalQueryRunner` seam is handed to features. So `replacePostings` cannot wrap
|
|
14
|
+
* the whole diff in a single DB transaction. Instead it is safe BY CONSTRUCTION:
|
|
15
|
+
* - Deletes target ONLY rows absent from `desired` (the "removed" set). A KEPT posting
|
|
16
|
+
* is never a delete target, so it is never removed.
|
|
17
|
+
* - Creates target ONLY rows absent from `current` (the "added" set).
|
|
18
|
+
* Therefore the membership NEVER lands empty mid-op: even if a create fails after the
|
|
19
|
+
* delete phase, every kept posting is still present (never deleted). Ordering is
|
|
20
|
+
* delete-first, then create; a failure surfaces to the caller (fail-closed) with the
|
|
21
|
+
* kept set intact. (Covered by the mid-op-safety tests in posting.service.spec.ts.)
|
|
22
|
+
*/
|
|
23
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
24
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
25
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
26
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
27
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
28
|
+
};
|
|
29
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
30
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
31
|
+
};
|
|
32
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
33
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
34
|
+
};
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.PostingService = void 0;
|
|
37
|
+
const common_1 = require("@nestjs/common");
|
|
38
|
+
const engine_1 = require("@astram/engine");
|
|
39
|
+
const posting_version_bump_js_1 = require("./posting-version-bump.js");
|
|
40
|
+
/** The field keys whose values are effective-window epoch-ms bounds (normalized on write). */
|
|
41
|
+
const WINDOW_KEYS = new Set(['effectiveFrom', 'effectiveUntil']);
|
|
42
|
+
/**
|
|
43
|
+
* Normalize an effective-window bound to epoch-ms (a `number`), or `null` if empty/unparseable.
|
|
44
|
+
* The membership_posting object declares these fields as `number` (BIGINT epoch-ms) so the
|
|
45
|
+
* engine binds an INTEGER, never a driver-serialized `Date` — the timezone-immune fix. A bare
|
|
46
|
+
* `YYYY-MM-DD` (the native date input) is parsed by `Date.parse` as UTC midnight (per the ES
|
|
47
|
+
* spec's date-only form), so the stored instant is timezone-stable regardless of host tz.
|
|
48
|
+
*/
|
|
49
|
+
function toEpochMs(value) {
|
|
50
|
+
if (value === null || value === undefined || value === '')
|
|
51
|
+
return null;
|
|
52
|
+
if (typeof value === 'number')
|
|
53
|
+
return Number.isFinite(value) ? value : null;
|
|
54
|
+
if (typeof value === 'string') {
|
|
55
|
+
const ms = Date.parse(value);
|
|
56
|
+
return Number.isNaN(ms) ? null : ms;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/** The identity a human calls "the same posting": role + optional dept + optional branch. */
|
|
61
|
+
function naturalKey(p) {
|
|
62
|
+
return `${p.roleUuid}|${p.departmentUuid ?? ''}|${p.branchUuid ?? ''}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the engine field map from a desired posting, dropping empty optionals and
|
|
66
|
+
* normalizing the effective-window bounds to epoch-ms integers.
|
|
67
|
+
*
|
|
68
|
+
* Two concerns:
|
|
69
|
+
* 1. Drop `undefined`/`null`/`''` optional keys. This is load-bearing: the editor submits
|
|
70
|
+
* `departmentUuid`/`branchUuid`/`effectiveFrom`/`effectiveUntil` as `null`/`''` for an
|
|
71
|
+
* empty (company-wide / open-ended) posting, but the engine's validate stage runs its
|
|
72
|
+
* type-coercer on any PRESENT column and REJECTS `null`. Omitting the key entirely makes
|
|
73
|
+
* the stage skip it (absent + optional → no coercion), so the column defaults to SQL NULL
|
|
74
|
+
* — exactly what a company-wide / no-expiry posting means.
|
|
75
|
+
* 2. Convert the window bounds (`effectiveFrom`/`effectiveUntil`) from the client's date
|
|
76
|
+
* string / ISO / number to epoch-ms via `toEpochMs`, because the object declares them as
|
|
77
|
+
* `number` (BIGINT epoch-ms) — the timezone-immune storage. An unparseable/empty bound
|
|
78
|
+
* is dropped (→ SQL NULL = open-ended), never coerced to a bogus number.
|
|
79
|
+
*/
|
|
80
|
+
function stripEmpty(input) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const [k, v] of Object.entries(input)) {
|
|
83
|
+
if (WINDOW_KEYS.has(k)) {
|
|
84
|
+
const ms = toEpochMs(v);
|
|
85
|
+
if (ms !== null)
|
|
86
|
+
out[k] = ms; // else omit → SQL NULL (open-ended)
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (v !== undefined && v !== null && v !== '')
|
|
90
|
+
out[k] = v;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
let PostingService = class PostingService {
|
|
95
|
+
records;
|
|
96
|
+
versionBump;
|
|
97
|
+
constructor(records, versionBump) {
|
|
98
|
+
this.records = records;
|
|
99
|
+
this.versionBump = versionBump;
|
|
100
|
+
}
|
|
101
|
+
/** List a membership's postings (tenant-scoped by the pipe). */
|
|
102
|
+
async listPostings(membershipUuid) {
|
|
103
|
+
const page = await this.records.list('membership_posting', { where: { membershipUuid } });
|
|
104
|
+
return page.rows;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Replace a membership's full posting set with `desired`, applying the minimal diff.
|
|
108
|
+
* Compares current vs desired by the natural key `roleUuid|departmentUuid|branchUuid`:
|
|
109
|
+
* deletes removed rows, creates added rows, leaves kept rows untouched. See the file
|
|
110
|
+
* header for the atomicity / mid-op-safety reasoning (delete-then-create is safe because
|
|
111
|
+
* kept postings are never delete targets, so the membership never lands empty).
|
|
112
|
+
*/
|
|
113
|
+
async replacePostings(membershipUuid, desired) {
|
|
114
|
+
const current = (await this.records.list('membership_posting', { where: { membershipUuid } })).rows;
|
|
115
|
+
const desiredKeys = new Set(desired.map(naturalKey));
|
|
116
|
+
const currentKeys = new Set(current.map((r) => naturalKey(r.fields)));
|
|
117
|
+
// Track whether the diff actually changed anything — only a real add/remove of a role
|
|
118
|
+
// assignment need invalidate cached permission facts (a no-op replace must not bump).
|
|
119
|
+
let changed = false;
|
|
120
|
+
// Delete removed: rows in current whose key is not in desired. Kept rows are skipped.
|
|
121
|
+
for (const r of current) {
|
|
122
|
+
const key = naturalKey(r.fields);
|
|
123
|
+
if (!desiredKeys.has(key)) {
|
|
124
|
+
await this.records.delete('membership_posting', r.id);
|
|
125
|
+
changed = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Create added: desired rows whose key is not already present. Only real fields are
|
|
129
|
+
// sent (undefined/null/empty optionals are dropped so the engine gets a clean field map).
|
|
130
|
+
for (const d of desired) {
|
|
131
|
+
if (!currentKeys.has(naturalKey(d))) {
|
|
132
|
+
await this.records.create('membership_posting', {
|
|
133
|
+
fields: { membershipUuid, ...stripEmpty(d) },
|
|
134
|
+
});
|
|
135
|
+
changed = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ADR-0011 STALENESS FIX: a posting add/remove changes the effective roles (→ coarse
|
|
139
|
+
// grants) of the membership's user in that company, so bump the tenant's permission
|
|
140
|
+
// version — every live session in that tenant then reloads its cached facts on the next
|
|
141
|
+
// request (a role gain/loss lands within the version bound). Only when something actually
|
|
142
|
+
// changed, and only when the bump collaborator is wired (absent in unit tests → no-op).
|
|
143
|
+
// Best-effort + fail-safe: the posting write already succeeded, so a bump failure must
|
|
144
|
+
// NOT surface as a 500 that hides a successful assignment change — the cache is instead
|
|
145
|
+
// invalidated by the next legit bump / the facts' natural TTL. We log-swallow it.
|
|
146
|
+
if (changed && this.versionBump) {
|
|
147
|
+
const tenantUuid = await this.resolveMembershipTenant(membershipUuid);
|
|
148
|
+
if (tenantUuid) {
|
|
149
|
+
try {
|
|
150
|
+
await this.versionBump.bumpTenant(tenantUuid);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Fail-safe: never fail an applied posting change on a version-bump hiccup.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Resolve the tenant (company uuid) a membership belongs to — the tenant whose permission
|
|
160
|
+
* version a posting change must bump. Reads the parent `company_membership` through the
|
|
161
|
+
* pipe (tenant-scoped, no raw SQL), returning `companyUuid` or `null` if unresolvable
|
|
162
|
+
* (then no bump — fail-safe; never guesses a tenant).
|
|
163
|
+
*/
|
|
164
|
+
async resolveMembershipTenant(membershipUuid) {
|
|
165
|
+
try {
|
|
166
|
+
const m = await this.records.get('company_membership', membershipUuid);
|
|
167
|
+
const companyUuid = m?.fields?.companyUuid;
|
|
168
|
+
return typeof companyUuid === 'string' && companyUuid.length > 0 ? companyUuid : null;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
exports.PostingService = PostingService;
|
|
176
|
+
exports.PostingService = PostingService = __decorate([
|
|
177
|
+
(0, common_1.Injectable)(),
|
|
178
|
+
__param(1, (0, common_1.Optional)()),
|
|
179
|
+
__param(1, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
|
|
180
|
+
__metadata("design:paramtypes", [engine_1.RecordsService, Object])
|
|
181
|
+
], PostingService);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { OrgCatalogService } from './org-catalog.service.js';
|
|
2
|
+
export declare class OrgCatalogController {
|
|
3
|
+
private readonly catalog;
|
|
4
|
+
constructor(catalog: OrgCatalogService);
|
|
5
|
+
/** A company's departments as `{ uuid, name }` options for the posting editor. */
|
|
6
|
+
listCompanyDepartments(companyUuid: string): Promise<{
|
|
7
|
+
rows: import("../shared/named-option.js").NamedOption[];
|
|
8
|
+
}>;
|
|
9
|
+
/** A company's branches as `{ uuid, name }` options for the posting editor. */
|
|
10
|
+
listCompanyBranches(companyUuid: string): Promise<{
|
|
11
|
+
rows: import("../shared/named-option.js").NamedOption[];
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=org-catalog.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-catalog.controller.d.ts","sourceRoot":"","sources":["../../../src/features/org-structure/org-catalog.controller.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAU7D,qBACa,oBAAoB;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,iBAAiB;IAEvD,kFAAkF;IAG5E,sBAAsB,CAAuB,WAAW,EAAE,MAAM;;;IAKtE,+EAA+E;IAGzE,mBAAmB,CAAuB,WAAW,EAAE,MAAM;;;CAIpE"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.OrgCatalogController = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const guard_1 = require("@astram/guard");
|
|
18
|
+
const org_catalog_service_js_1 = require("./org-catalog.service.js");
|
|
19
|
+
// ── Company-scoped catalogs (dept/branch option lists for the posting editor) ──
|
|
20
|
+
//
|
|
21
|
+
// These back the Assignments editor's company-scoped Department/Branch dropdowns.
|
|
22
|
+
// Each returns `{ rows: [{ uuid, name }] }` — the uuid is the option VALUE the client
|
|
23
|
+
// stores, the name is the display LABEL. Read through the pipe (list the
|
|
24
|
+
// company_department/company_branch junction → resolve each catalog uuid to a name in
|
|
25
|
+
// the service), so no raw uuid is ever a label.
|
|
26
|
+
let OrgCatalogController = class OrgCatalogController {
|
|
27
|
+
catalog;
|
|
28
|
+
constructor(catalog) {
|
|
29
|
+
this.catalog = catalog;
|
|
30
|
+
}
|
|
31
|
+
/** A company's departments as `{ uuid, name }` options for the posting editor. */
|
|
32
|
+
async listCompanyDepartments(companyUuid) {
|
|
33
|
+
const rows = await this.catalog.listCompanyDepartments(companyUuid);
|
|
34
|
+
return { rows };
|
|
35
|
+
}
|
|
36
|
+
/** A company's branches as `{ uuid, name }` options for the posting editor. */
|
|
37
|
+
async listCompanyBranches(companyUuid) {
|
|
38
|
+
const rows = await this.catalog.listCompanyBranches(companyUuid);
|
|
39
|
+
return { rows };
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
exports.OrgCatalogController = OrgCatalogController;
|
|
43
|
+
__decorate([
|
|
44
|
+
(0, common_1.Get)('companies/:companyUuid/departments'),
|
|
45
|
+
(0, guard_1.Classified)({ typeKey: 'company_department', action: 'read' }),
|
|
46
|
+
__param(0, (0, common_1.Param)('companyUuid')),
|
|
47
|
+
__metadata("design:type", Function),
|
|
48
|
+
__metadata("design:paramtypes", [String]),
|
|
49
|
+
__metadata("design:returntype", Promise)
|
|
50
|
+
], OrgCatalogController.prototype, "listCompanyDepartments", null);
|
|
51
|
+
__decorate([
|
|
52
|
+
(0, common_1.Get)('companies/:companyUuid/branches'),
|
|
53
|
+
(0, guard_1.Classified)({ typeKey: 'company_branch', action: 'read' }),
|
|
54
|
+
__param(0, (0, common_1.Param)('companyUuid')),
|
|
55
|
+
__metadata("design:type", Function),
|
|
56
|
+
__metadata("design:paramtypes", [String]),
|
|
57
|
+
__metadata("design:returntype", Promise)
|
|
58
|
+
], OrgCatalogController.prototype, "listCompanyBranches", null);
|
|
59
|
+
exports.OrgCatalogController = OrgCatalogController = __decorate([
|
|
60
|
+
(0, common_1.Controller)('identity'),
|
|
61
|
+
__metadata("design:paramtypes", [org_catalog_service_js_1.OrgCatalogService])
|
|
62
|
+
], OrgCatalogController);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrgCatalogService — company-scoped Department/Branch OPTION lists for the Assignments
|
|
3
|
+
* editor dropdowns, via the RecordsService facade (no actor threading). Reads the
|
|
4
|
+
* company_department/company_branch junctions and resolves each catalog uuid → display
|
|
5
|
+
* name (never a raw uuid label). No raw SQL.
|
|
6
|
+
*/
|
|
7
|
+
import { RecordsService } from '@astram/engine';
|
|
8
|
+
import { type NamedOption } from '../shared/named-option.js';
|
|
9
|
+
export declare class OrgCatalogService {
|
|
10
|
+
private readonly records;
|
|
11
|
+
constructor(records: RecordsService);
|
|
12
|
+
/** A company's departments as `{ uuid, name }` options for the posting editor. */
|
|
13
|
+
listCompanyDepartments(companyUuid: string): Promise<NamedOption[]>;
|
|
14
|
+
/** A company's branches as `{ uuid, name }` options for the posting editor. */
|
|
15
|
+
listCompanyBranches(companyUuid: string): Promise<NamedOption[]>;
|
|
16
|
+
/** Shared reader for a company-scoped catalog junction; lists then name-resolves. */
|
|
17
|
+
private listCompanyCatalog;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=org-catalog.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-catalog.service.d.ts","sourceRoot":"","sources":["../../../src/features/org-structure/org-catalog.service.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAE7D,qBACa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,cAAc;IAEpD,kFAAkF;IAClF,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAInE,+EAA+E;IAC/E,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAIhE,qFAAqF;YACvE,kBAAkB;CAiBjC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OrgCatalogService — company-scoped Department/Branch OPTION lists for the Assignments
|
|
4
|
+
* editor dropdowns, via the RecordsService facade (no actor threading). Reads the
|
|
5
|
+
* company_department/company_branch junctions and resolves each catalog uuid → display
|
|
6
|
+
* name (never a raw uuid label). No raw SQL.
|
|
7
|
+
*/
|
|
8
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
9
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
10
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
11
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
12
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
13
|
+
};
|
|
14
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
15
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.OrgCatalogService = void 0;
|
|
19
|
+
const common_1 = require("@nestjs/common");
|
|
20
|
+
const engine_1 = require("@astram/engine");
|
|
21
|
+
let OrgCatalogService = class OrgCatalogService {
|
|
22
|
+
records;
|
|
23
|
+
constructor(records) {
|
|
24
|
+
this.records = records;
|
|
25
|
+
}
|
|
26
|
+
/** A company's departments as `{ uuid, name }` options for the posting editor. */
|
|
27
|
+
listCompanyDepartments(companyUuid) {
|
|
28
|
+
return this.listCompanyCatalog('company_department', 'departmentUuid', 'department', companyUuid);
|
|
29
|
+
}
|
|
30
|
+
/** A company's branches as `{ uuid, name }` options for the posting editor. */
|
|
31
|
+
listCompanyBranches(companyUuid) {
|
|
32
|
+
return this.listCompanyCatalog('company_branch', 'branchUuid', 'branch', companyUuid);
|
|
33
|
+
}
|
|
34
|
+
/** Shared reader for a company-scoped catalog junction; lists then name-resolves. */
|
|
35
|
+
async listCompanyCatalog(junctionTypeKey, refFieldKey, catalogTypeKey, companyUuid) {
|
|
36
|
+
const page = await this.records.list(junctionTypeKey, { where: { companyUuid } });
|
|
37
|
+
const options = [];
|
|
38
|
+
for (const row of page.rows) {
|
|
39
|
+
const refUuid = row.fields[refFieldKey];
|
|
40
|
+
if (typeof refUuid !== 'string' || refUuid.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const view = await this.records.get(catalogTypeKey, refUuid);
|
|
43
|
+
const name = view?.fields?.name;
|
|
44
|
+
options.push({ uuid: refUuid, name: typeof name === 'string' && name.length > 0 ? name : '(unknown)' });
|
|
45
|
+
}
|
|
46
|
+
return options;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
exports.OrgCatalogService = OrgCatalogService;
|
|
50
|
+
exports.OrgCatalogService = OrgCatalogService = __decorate([
|
|
51
|
+
(0, common_1.Injectable)(),
|
|
52
|
+
__metadata("design:paramtypes", [engine_1.RecordsService])
|
|
53
|
+
], OrgCatalogService);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RoleService } from './role.service.js';
|
|
2
|
+
import { type ListQueryDto } from '../shared/list-query.js';
|
|
3
|
+
export declare class RoleController {
|
|
4
|
+
private readonly roles;
|
|
5
|
+
constructor(roles: RoleService);
|
|
6
|
+
/**
|
|
7
|
+
* List roles — gated on `identity:role:read` (see class doc). Server-side DataTable:
|
|
8
|
+
* `?page&limit&search&sortBy&sortOrder`. Returns `{ total, rows, page, limit }` —
|
|
9
|
+
* `total` reflects the FILTERED set so the client can page the true result count.
|
|
10
|
+
*/
|
|
11
|
+
listRoles(query: ListQueryDto): Promise<{
|
|
12
|
+
total: number;
|
|
13
|
+
rows: readonly import("packages/engine/dist/types.js").RecordView[];
|
|
14
|
+
page: number;
|
|
15
|
+
limit: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** Create a role — gated on `identity:role:create`. */
|
|
18
|
+
createRole(body: {
|
|
19
|
+
name?: string;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
id: import("packages/engine/dist/types.js").RecordId;
|
|
22
|
+
}>;
|
|
23
|
+
/** Read one role — gated on `identity:role:read` (CONTROL-plane; root, the identity
|
|
24
|
+
* administrator, may read roles). */
|
|
25
|
+
getRole(uuid: string): Promise<import("packages/engine/dist/types.js").RecordView | null>;
|
|
26
|
+
/** Update a role — gated on `identity:role:update`. */
|
|
27
|
+
updateRole(uuid: string, body: {
|
|
28
|
+
name?: string;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
success: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
/** Delete a role — gated on `identity:role:delete`. */
|
|
33
|
+
deleteRole(uuid: string): Promise<{
|
|
34
|
+
success: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=role.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role.controller.d.ts","sourceRoot":"","sources":["../../../src/features/role/role.controller.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAK5E,qBACa,cAAc;IACb,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,WAAW;IAE/C;;;;OAIG;IAGG,SAAS,CAAU,KAAK,EAAE,YAAY;;;;;;IAM5C,uDAAuD;IAGjD,UAAU,CAAS,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAKhD;0CACsC;IAGtC,OAAO,CAAgB,IAAI,EAAE,MAAM;IAInC,uDAAuD;IAGjD,UAAU,CAAgB,IAAI,EAAE,MAAM,EAAU,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAK7E,uDAAuD;IAGjD,UAAU,CAAgB,IAAI,EAAE,MAAM;;;CAI7C"}
|