@plantops/contracts 0.1.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 (60) hide show
  1. package/README.md +11 -0
  2. package/dist/audit.d.ts +149 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +87 -0
  5. package/dist/bindings.d.ts +125 -0
  6. package/dist/bindings.d.ts.map +1 -0
  7. package/dist/bindings.js +40 -0
  8. package/dist/clients.d.ts +240 -0
  9. package/dist/clients.d.ts.map +1 -0
  10. package/dist/clients.js +49 -0
  11. package/dist/constants.d.ts +91 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/constants.js +92 -0
  14. package/dist/entitlements.d.ts +135 -0
  15. package/dist/entitlements.d.ts.map +1 -0
  16. package/dist/entitlements.js +43 -0
  17. package/dist/errors.d.ts +68 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +83 -0
  20. package/dist/grants.d.ts +70 -0
  21. package/dist/grants.d.ts.map +1 -0
  22. package/dist/grants.js +13 -0
  23. package/dist/index.d.ts +34 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +33 -0
  26. package/dist/jwt.d.ts +155 -0
  27. package/dist/jwt.d.ts.map +1 -0
  28. package/dist/jwt.js +49 -0
  29. package/dist/lib/contracts.d.ts +2 -0
  30. package/dist/lib/contracts.d.ts.map +1 -0
  31. package/dist/lib/contracts.js +3 -0
  32. package/dist/manifest.d.ts +136 -0
  33. package/dist/manifest.d.ts.map +1 -0
  34. package/dist/manifest.js +22 -0
  35. package/dist/nav.d.ts +53 -0
  36. package/dist/nav.d.ts.map +1 -0
  37. package/dist/nav.js +25 -0
  38. package/dist/pagination.d.ts +28 -0
  39. package/dist/pagination.d.ts.map +1 -0
  40. package/dist/pagination.js +27 -0
  41. package/dist/registry.d.ts +183 -0
  42. package/dist/registry.d.ts.map +1 -0
  43. package/dist/registry.js +24 -0
  44. package/dist/roles.d.ts +215 -0
  45. package/dist/roles.d.ts.map +1 -0
  46. package/dist/roles.js +45 -0
  47. package/dist/scopes.d.ts +220 -0
  48. package/dist/scopes.d.ts.map +1 -0
  49. package/dist/scopes.js +108 -0
  50. package/dist/service-accounts.d.ts +81 -0
  51. package/dist/service-accounts.d.ts.map +1 -0
  52. package/dist/service-accounts.js +35 -0
  53. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  54. package/dist/type-assertions.d.ts +18 -0
  55. package/dist/type-assertions.d.ts.map +1 -0
  56. package/dist/type-assertions.js +8 -0
  57. package/dist/users.d.ts +364 -0
  58. package/dist/users.d.ts.map +1 -0
  59. package/dist/users.js +134 -0
  60. package/package.json +39 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * User contract — the WHO of the access equation (Doc 01 §3.6, Doc 06 §8).
3
+ *
4
+ * A user is a human identity belonging to **exactly one** client. There is no
5
+ * global user and there is not going to be one: `email` is unique per client and
6
+ * login is by `(client_slug, email)` (Doc 03 §3), so the same address is a
7
+ * different person in two tenants and neither of them can see the other.
8
+ *
9
+ * ## No credential is ever on this shape
10
+ *
11
+ * Not the hash — that lives on `user_identity` (Doc 01 §4.6), so an admin list
12
+ * cannot carry one by accident — and not a plaintext password either, because
13
+ * {@link CreateUserRequest} has no password field. A user created here has no
14
+ * credential at all until they set one through the tokenized reset flow of
15
+ * Doc 03 §7, which is the same flow Doc 09 §3.3 puts behind the detail screen's
16
+ * *reset password* button. That makes creation and invitation one act instead of
17
+ * two, and means no operator ever handles somebody else's password.
18
+ *
19
+ * The single exception on the whole surface is `POST /iam/clients/:id/admins`
20
+ * ({@link ClientAdminDTO}), where a platform operator provisions a tenant's very
21
+ * first administrator and there is nobody yet who *could* invite them.
22
+ *
23
+ * ## Status is a state machine, not a field
24
+ *
25
+ * {@link UserStatus} looks like an ordinary enum column and is not: `locked` and
26
+ * `disabled` both force-log-out every session the user has (Doc 03 §6, Doc 04
27
+ * §7), and `disabled` is one-way — an offboarded account is not quietly walked
28
+ * back to `active` through the same control that unlocks a mistyped password.
29
+ * {@link UpdateUserRequest} therefore accepts a status, but the transitions it
30
+ * may express are the ones Doc 03 §8's table allows.
31
+ *
32
+ * Field naming is snake_case, matching every other published shape here.
33
+ */
34
+ /**
35
+ * The three account states (Doc 03 §8).
36
+ *
37
+ * | State | Login | Meaning |
38
+ * |---|---|---|
39
+ * | `active` | allowed | the ordinary case |
40
+ * | `locked` | refused `423` | an administrator, or the failed-attempt policy — the "Account Locked Users" list of Doc 09 §3.3 |
41
+ * | `disabled` | refused | offboarded, sessions revoked |
42
+ *
43
+ * Spelled here rather than imported from `@plantops/db`, which contracts must
44
+ * not depend on — it has zero dependencies by design (Doc 08 §3). The Postgres
45
+ * enum is the same three values in the same order, and `libs/db`'s
46
+ * `entities.spec.ts` asserts the two spellings against each other so they cannot
47
+ * drift into a status the API accepts and the column rejects. Same arrangement
48
+ * as {@link ClientStatus} and {@link ServiceAccountStatus}.
49
+ */
50
+ export declare const UserStatus: {
51
+ readonly ACTIVE: "active";
52
+ readonly LOCKED: "locked";
53
+ readonly DISABLED: "disabled";
54
+ };
55
+ export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
56
+ export declare const USER_STATUS_VALUES: readonly ["active", "locked", "disabled"];
57
+ /**
58
+ * One user of one tenant (Doc 01 §3.6).
59
+ *
60
+ * `is_client_admin` is published but not settable: it is Doc 01 §3.6's "shortcut
61
+ * flag; still enforced via permissions", the interim stand-in the client-tier
62
+ * services read until Session 23 turns tenant administration into an ordinary
63
+ * `iam.client.*` binding. A screen still has to be able to *show* who holds it —
64
+ * an admin list in which the administrators are indistinguishable is not much of
65
+ * a list — so it is on the read shape and absent from every write one.
66
+ */
67
+ export interface UserDTO {
68
+ id: string;
69
+ client_id: string;
70
+ /** Lowercased on write; half of the login credential (Doc 03 §3). */
71
+ email: string;
72
+ full_name: string;
73
+ /** Optional, and reserved for the WhatsApp channel of Doc 03 §10. */
74
+ phone: string | null;
75
+ status: UserStatus;
76
+ /** Interim tenant-administration flag — read-only on this surface. */
77
+ is_client_admin: boolean;
78
+ /** ISO-8601. */
79
+ created_at: string;
80
+ updated_at: string;
81
+ }
82
+ /**
83
+ * One grant this user holds, as the detail screen's *bindings* sub-panel renders
84
+ * it (Doc 09 §3.3, Doc 01 §4.5).
85
+ *
86
+ * A projection of `role_binding` **from the user's side**, which is why it names
87
+ * no subject: the subject is the user this arrived with. Session 20's
88
+ * `/iam/role-bindings` surface publishes the symmetric shape — one that names
89
+ * the subject and is filterable by role and scope — and the two are deliberately
90
+ * separate: this one exists to answer "what can this person do", and a panel
91
+ * that repeated the person's own id in every row would be answering a question
92
+ * nobody asked.
93
+ *
94
+ * `role_name` and `scope_node_path` are joined in rather than left to the caller
95
+ * to resolve, because a binding rendered as two uuids is not a rendering of
96
+ * anything.
97
+ */
98
+ export interface UserBindingDTO {
99
+ /** The `role_binding` row — what `DELETE /iam/role-bindings/:id` takes. */
100
+ id: string;
101
+ role_id: string;
102
+ role_name: string;
103
+ scope_node_id: string;
104
+ scope_node_name: string;
105
+ /** The materialized `ltree` path — `n_<hex>` labels, never display names (Doc 01 §3.5). */
106
+ scope_node_path: string;
107
+ /** ISO-8601, or `null` for a grant that does not expire (Doc 01 §4.5). */
108
+ expires_at: string | null;
109
+ /**
110
+ * Whether {@link expires_at} has passed.
111
+ *
112
+ * Expired bindings are **listed, not hidden**: a grant that lapsed last Friday
113
+ * is the answer to "why did this stop working", and a panel that silently
114
+ * dropped the row would leave that question unanswerable. Resolution ignores
115
+ * them (Doc 04 §4), so the flag is what keeps the screen honest about the
116
+ * difference between held and effective.
117
+ */
118
+ expired: boolean;
119
+ created_at: string;
120
+ }
121
+ /**
122
+ * `GET /iam/users/:id` — the profile with its grants (Doc 06 §8, Doc 09 §3.3).
123
+ *
124
+ * Bindings are inlined here and deliberately absent from {@link UserDTO}: the
125
+ * list is a page of up to a hundred people and a grant set per row is a payload
126
+ * nobody reads, while the detail screen cannot render without them. The same
127
+ * split {@link RoleDTO} makes for its permission mapping, for the same reason.
128
+ */
129
+ export interface UserDetailDTO extends UserDTO {
130
+ bindings: UserBindingDTO[];
131
+ }
132
+ /**
133
+ * `POST /iam/users` body (Doc 06 §8, Doc 09 §3.3).
134
+ *
135
+ * The client is the caller's own, taken from the token's `cid` and from nowhere
136
+ * else. `is_client_admin` is absent for the reason `is_system` is absent from
137
+ * {@link CreateRoleRequest} — a flag that grants administration is not a field
138
+ * of a create form — and there is no password, for the reason in the header.
139
+ *
140
+ * `status` is Doc 09 §3.3's "initial status", and it is a genuine field rather
141
+ * than a fixed `active`: a tenant onboarding a plant in advance of its opening
142
+ * creates the accounts now and turns them on later, and the alternative — create
143
+ * active, then immediately disable — is two audit records for one intention.
144
+ */
145
+ export interface CreateUserRequest {
146
+ email: string;
147
+ full_name: string;
148
+ phone?: string;
149
+ /** Defaults to `active`. */
150
+ status?: UserStatus;
151
+ }
152
+ /**
153
+ * `PATCH /iam/users/:id` body — Doc 06 §8's "update, lock, unlock, disable".
154
+ *
155
+ * One route for the profile and the state machine, because Doc 06 §8 gives them
156
+ * one, and because they are the same screen's save button (Doc 09 §3.3). What
157
+ * separates them is what happens underneath: a changed `full_name` is an
158
+ * `update`, while a changed `status` runs a transition that revokes sessions and
159
+ * invalidates grants (Doc 03 §8, Doc 04 §7).
160
+ *
161
+ * `email` is editable — it is the only way to correct a typo in an address
162
+ * nobody can log in with, and there is no `DELETE /iam/users/:id` to fall back
163
+ * on (offboarding is `disabled`, so that a person's access history survives
164
+ * them). Changing it changes half the login credential, which is why it is
165
+ * audited with its before and after like any other field.
166
+ *
167
+ * `is_client_admin` is not here. Promoting somebody is an access grant, and
168
+ * access grants are role bindings (Session 20) — routing one through a boolean
169
+ * on a profile form would put the tenant's most consequential change on the
170
+ * screen least likely to be reviewed.
171
+ */
172
+ export interface UpdateUserRequest {
173
+ email?: string;
174
+ full_name?: string;
175
+ phone?: string;
176
+ status?: UserStatus;
177
+ }
178
+ /**
179
+ * How many data rows one `POST /iam/users/bulk` may carry.
180
+ *
181
+ * The same argument {@link MAX_PERMISSIONS_PER_ROLE} makes: the whole upload is
182
+ * validated and written inside one request transaction, so an unbounded body is
183
+ * an unbounded transaction — and this one holds it open while it writes user
184
+ * rows an administrator is about to start binding roles to.
185
+ *
186
+ * Five hundred rather than two hundred because the thing being uploaded is a
187
+ * staff list rather than a hand-curated selection: a plant's shift roster is a
188
+ * few hundred people, and splitting one onboarding into three files to satisfy a
189
+ * limit is friction with nothing behind it. It is still well inside the 64 kB
190
+ * body ceiling of Doc 06 §1 at any realistic row width, so the request is
191
+ * refused for its row count — which names the problem — rather than for its
192
+ * byte count, which does not.
193
+ */
194
+ export declare const MAX_BULK_USER_ROWS = 500;
195
+ /**
196
+ * The two shapes a bulk upload may take (Doc 06 §8's "CSV/JSON").
197
+ *
198
+ * Both arrive as `application/json`, and that is deliberate rather than a
199
+ * compromise. A `multipart/form-data` upload would need a second body parser in
200
+ * front of a surface that has exactly one (`body-parser.middleware.ts`), a
201
+ * second failure taxonomy to translate into the Doc 06 §2 envelope, and a second
202
+ * ceiling to publish — all so that a browser could send bytes it has already
203
+ * read into memory to render the operator's preview. Carrying the CSV as a
204
+ * string inside the JSON envelope keeps one parser, one ceiling and one error
205
+ * shape.
206
+ */
207
+ export declare const BULK_USER_UPLOAD_FORMATS: readonly ["csv", "json"];
208
+ export type BulkUserUploadFormat = (typeof BULK_USER_UPLOAD_FORMATS)[number];
209
+ /**
210
+ * The CSV columns a bulk upload is read from, in the order the template writes
211
+ * them.
212
+ *
213
+ * Matching is by header **name**, case- and whitespace-insensitive, never by
214
+ * position: a spreadsheet round-trip reorders columns, and a file whose `phone`
215
+ * column silently became its `status` column is the failure mode a positional
216
+ * reader cannot detect. Columns not named here are ignored, which is the same
217
+ * treatment `z.object` gives an unknown key on the JSON side (`users.dto.ts`).
218
+ *
219
+ * `email` and `full_name` are required *of the header*; `phone` and `status` may
220
+ * be absent entirely, and an empty cell in either is the field being omitted
221
+ * rather than being set to the empty string.
222
+ */
223
+ export declare const BULK_USER_CSV_COLUMNS: readonly ["email", "full_name", "phone", "status"];
224
+ /**
225
+ * `POST /iam/users/bulk` body (Doc 06 §8).
226
+ *
227
+ * Discriminated on `format` rather than inferred from which field is present,
228
+ * so an upload that carries neither — or both — is refused by the schema with a
229
+ * message that names the field, instead of being guessed at.
230
+ *
231
+ * The `json` arm's rows are typed as {@link CreateUserRequest} here because that
232
+ * is what a well-formed row *is*. The server-side schema deliberately types the
233
+ * array as unknown and validates each element separately: a body validated
234
+ * element-wise by the pipe would fail the whole request on its first bad row,
235
+ * and the per-row report this endpoint exists to produce would never be reached
236
+ * (see `users.dto.ts`).
237
+ */
238
+ export type BulkUserUploadRequest = {
239
+ format: 'csv';
240
+ content: string;
241
+ } | {
242
+ format: 'json';
243
+ users: CreateUserRequest[];
244
+ };
245
+ /**
246
+ * What happened to one row (Doc 09 §3.3's "created / skipped / errored").
247
+ *
248
+ * The distinction between the two failures is *whose* problem it is:
249
+ *
250
+ * - `skipped` — the row was well-formed and describes a user who already
251
+ * exists, either earlier in this same file or already in the tenant. Nothing
252
+ * is wrong with the file; re-uploading it after adding people is the ordinary
253
+ * way this endpoint gets used, and every previously-uploaded row skipping is
254
+ * what makes that safe.
255
+ * - `errored` — the row could not be read as a user at all. It is a defect in
256
+ * the file, and it is the only status whose rows an operator has to go and
257
+ * fix.
258
+ */
259
+ export declare const BulkUserRowStatus: {
260
+ readonly CREATED: "created";
261
+ readonly SKIPPED: "skipped";
262
+ readonly ERRORED: "errored";
263
+ };
264
+ export type BulkUserRowStatus = (typeof BulkUserRowStatus)[keyof typeof BulkUserRowStatus];
265
+ export declare const BULK_USER_ROW_STATUS_VALUES: readonly ["created", "skipped", "errored"];
266
+ /**
267
+ * One line of the per-row result report (Doc 06 §8, Doc 09 §3.3).
268
+ *
269
+ * `row` is 1-based and counts **data** rows, so row 1 is the first person in the
270
+ * file whichever format it arrived in — a CSV header is not row 1, and the
271
+ * report an operator reads beside their spreadsheet should not be off by one
272
+ * against it.
273
+ *
274
+ * `email` is echoed as submitted, normalized where it could be parsed and `null`
275
+ * where it could not, because a report keyed only by row number is one an
276
+ * operator has to hold two documents open to read.
277
+ */
278
+ export interface BulkUserRowResult {
279
+ row: number;
280
+ email: string | null;
281
+ status: BulkUserRowStatus;
282
+ /**
283
+ * Why, for everything that is not `created`.
284
+ *
285
+ * Always present on `skipped` and `errored` and always absent on `created`:
286
+ * Doc 09 §3.3's report table has a reason column, and a blank cell beside a
287
+ * refusal is the one thing it must never show.
288
+ */
289
+ reason?: string;
290
+ /** The created user's id, or `null` — the handle the UI links the row to. */
291
+ user_id: string | null;
292
+ }
293
+ /**
294
+ * `POST /iam/users/bulk` → the per-row report and its counts (Doc 06 §8).
295
+ *
296
+ * A `200`, not a `201`, and not a `207`. The request as a whole succeeded — it
297
+ * was read, every row was adjudicated, and the answer is this report — so the
298
+ * status describes the request and the report describes the rows. A `201` would
299
+ * claim a location that does not exist (there are up to
300
+ * {@link MAX_BULK_USER_ROWS} of them), and a `207` is a WebDAV multi-status
301
+ * whose body shape is not this one and which the Doc 06 §2 code table does not
302
+ * contain.
303
+ *
304
+ * ## Partial success, stated precisely
305
+ *
306
+ * Valid rows commit even when others do not. What that means mechanically is
307
+ * that an invalid row is *never attempted*: every row is validated, and every
308
+ * duplicate resolved, before a single insert runs, so the write itself cannot
309
+ * fail on a row's account. The transaction is still all-or-nothing — an
310
+ * unexpected database failure rolls the whole upload back and answers `500`,
311
+ * leaving no half-loaded tenant — and `created` in this report therefore always
312
+ * means committed.
313
+ *
314
+ * `results` covers every row in file order, including the created ones, so the
315
+ * report can be rendered as the file with a verdict column rather than as three
316
+ * separate lists.
317
+ */
318
+ export interface BulkUserUploadResponse {
319
+ /** Rows read from the file — the length of {@link results}. */
320
+ total: number;
321
+ created: number;
322
+ skipped: number;
323
+ errored: number;
324
+ results: BulkUserRowResult[];
325
+ }
326
+ /**
327
+ * One place a user holds the role being asked about — Session 34's "scope
328
+ * context".
329
+ *
330
+ * The mirror image of {@link UserBindingDTO}: that one names the role and omits
331
+ * the subject, because it answers "what can this person do"; this one names the
332
+ * scope and omits the role, because it answers "who can do this, and where". A
333
+ * shape that carried both would be `role_binding` itself, which is Session 20's
334
+ * surface and a different question again.
335
+ */
336
+ export interface RoleScopeGrantDTO {
337
+ /** The `role_binding` row — what `DELETE /iam/role-bindings/:id` takes. */
338
+ binding_id: string;
339
+ scope_node_id: string;
340
+ scope_node_name: string;
341
+ /** The materialized `ltree` path — `n_<hex>` labels (Doc 01 §3.5). */
342
+ scope_node_path: string;
343
+ expires_at: string | null;
344
+ /** Whether {@link expires_at} has passed — see {@link UserBindingDTO.expired}. */
345
+ expired: boolean;
346
+ }
347
+ /**
348
+ * `GET /iam/users/by-role/:roleId` — one holder of the role (Doc 06 §8).
349
+ *
350
+ * A user appears **once**, with every scope they hold the role at gathered into
351
+ * {@link scopes}, rather than once per binding. "Who has this role" is a
352
+ * question about people, so somebody granted it at four plants is one row of the
353
+ * answer and not four — and the pagination envelope counts people, which is what
354
+ * a total under a role picker has to mean.
355
+ *
356
+ * Expired bindings are listed and flagged rather than dropped, for the reason
357
+ * {@link UserBindingDTO.expired} gives: a lapsed grant is the answer to "why did
358
+ * this stop working". A holder *all* of whose grants have expired still appears,
359
+ * with every entry flagged.
360
+ */
361
+ export interface UserByRoleDTO extends UserDTO {
362
+ scopes: RoleScopeGrantDTO[];
363
+ }
364
+ //# sourceMappingURL=users.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../src/users.ts"],"names":[],"mappings":"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"}
package/dist/users.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * User contract — the WHO of the access equation (Doc 01 §3.6, Doc 06 §8).
3
+ *
4
+ * A user is a human identity belonging to **exactly one** client. There is no
5
+ * global user and there is not going to be one: `email` is unique per client and
6
+ * login is by `(client_slug, email)` (Doc 03 §3), so the same address is a
7
+ * different person in two tenants and neither of them can see the other.
8
+ *
9
+ * ## No credential is ever on this shape
10
+ *
11
+ * Not the hash — that lives on `user_identity` (Doc 01 §4.6), so an admin list
12
+ * cannot carry one by accident — and not a plaintext password either, because
13
+ * {@link CreateUserRequest} has no password field. A user created here has no
14
+ * credential at all until they set one through the tokenized reset flow of
15
+ * Doc 03 §7, which is the same flow Doc 09 §3.3 puts behind the detail screen's
16
+ * *reset password* button. That makes creation and invitation one act instead of
17
+ * two, and means no operator ever handles somebody else's password.
18
+ *
19
+ * The single exception on the whole surface is `POST /iam/clients/:id/admins`
20
+ * ({@link ClientAdminDTO}), where a platform operator provisions a tenant's very
21
+ * first administrator and there is nobody yet who *could* invite them.
22
+ *
23
+ * ## Status is a state machine, not a field
24
+ *
25
+ * {@link UserStatus} looks like an ordinary enum column and is not: `locked` and
26
+ * `disabled` both force-log-out every session the user has (Doc 03 §6, Doc 04
27
+ * §7), and `disabled` is one-way — an offboarded account is not quietly walked
28
+ * back to `active` through the same control that unlocks a mistyped password.
29
+ * {@link UpdateUserRequest} therefore accepts a status, but the transitions it
30
+ * may express are the ones Doc 03 §8's table allows.
31
+ *
32
+ * Field naming is snake_case, matching every other published shape here.
33
+ */
34
+ /**
35
+ * The three account states (Doc 03 §8).
36
+ *
37
+ * | State | Login | Meaning |
38
+ * |---|---|---|
39
+ * | `active` | allowed | the ordinary case |
40
+ * | `locked` | refused `423` | an administrator, or the failed-attempt policy — the "Account Locked Users" list of Doc 09 §3.3 |
41
+ * | `disabled` | refused | offboarded, sessions revoked |
42
+ *
43
+ * Spelled here rather than imported from `@plantops/db`, which contracts must
44
+ * not depend on — it has zero dependencies by design (Doc 08 §3). The Postgres
45
+ * enum is the same three values in the same order, and `libs/db`'s
46
+ * `entities.spec.ts` asserts the two spellings against each other so they cannot
47
+ * drift into a status the API accepts and the column rejects. Same arrangement
48
+ * as {@link ClientStatus} and {@link ServiceAccountStatus}.
49
+ */
50
+ export const UserStatus = {
51
+ ACTIVE: 'active',
52
+ LOCKED: 'locked',
53
+ DISABLED: 'disabled',
54
+ };
55
+ export const USER_STATUS_VALUES = [
56
+ UserStatus.ACTIVE,
57
+ UserStatus.LOCKED,
58
+ UserStatus.DISABLED,
59
+ ];
60
+ // ── bulk upload (Doc 06 §8, Doc 09 §3.3) ────────────────────────────────────
61
+ /**
62
+ * How many data rows one `POST /iam/users/bulk` may carry.
63
+ *
64
+ * The same argument {@link MAX_PERMISSIONS_PER_ROLE} makes: the whole upload is
65
+ * validated and written inside one request transaction, so an unbounded body is
66
+ * an unbounded transaction — and this one holds it open while it writes user
67
+ * rows an administrator is about to start binding roles to.
68
+ *
69
+ * Five hundred rather than two hundred because the thing being uploaded is a
70
+ * staff list rather than a hand-curated selection: a plant's shift roster is a
71
+ * few hundred people, and splitting one onboarding into three files to satisfy a
72
+ * limit is friction with nothing behind it. It is still well inside the 64 kB
73
+ * body ceiling of Doc 06 §1 at any realistic row width, so the request is
74
+ * refused for its row count — which names the problem — rather than for its
75
+ * byte count, which does not.
76
+ */
77
+ export const MAX_BULK_USER_ROWS = 500;
78
+ /**
79
+ * The two shapes a bulk upload may take (Doc 06 §8's "CSV/JSON").
80
+ *
81
+ * Both arrive as `application/json`, and that is deliberate rather than a
82
+ * compromise. A `multipart/form-data` upload would need a second body parser in
83
+ * front of a surface that has exactly one (`body-parser.middleware.ts`), a
84
+ * second failure taxonomy to translate into the Doc 06 §2 envelope, and a second
85
+ * ceiling to publish — all so that a browser could send bytes it has already
86
+ * read into memory to render the operator's preview. Carrying the CSV as a
87
+ * string inside the JSON envelope keeps one parser, one ceiling and one error
88
+ * shape.
89
+ */
90
+ export const BULK_USER_UPLOAD_FORMATS = ['csv', 'json'];
91
+ /**
92
+ * The CSV columns a bulk upload is read from, in the order the template writes
93
+ * them.
94
+ *
95
+ * Matching is by header **name**, case- and whitespace-insensitive, never by
96
+ * position: a spreadsheet round-trip reorders columns, and a file whose `phone`
97
+ * column silently became its `status` column is the failure mode a positional
98
+ * reader cannot detect. Columns not named here are ignored, which is the same
99
+ * treatment `z.object` gives an unknown key on the JSON side (`users.dto.ts`).
100
+ *
101
+ * `email` and `full_name` are required *of the header*; `phone` and `status` may
102
+ * be absent entirely, and an empty cell in either is the field being omitted
103
+ * rather than being set to the empty string.
104
+ */
105
+ export const BULK_USER_CSV_COLUMNS = [
106
+ 'email',
107
+ 'full_name',
108
+ 'phone',
109
+ 'status',
110
+ ];
111
+ /**
112
+ * What happened to one row (Doc 09 §3.3's "created / skipped / errored").
113
+ *
114
+ * The distinction between the two failures is *whose* problem it is:
115
+ *
116
+ * - `skipped` — the row was well-formed and describes a user who already
117
+ * exists, either earlier in this same file or already in the tenant. Nothing
118
+ * is wrong with the file; re-uploading it after adding people is the ordinary
119
+ * way this endpoint gets used, and every previously-uploaded row skipping is
120
+ * what makes that safe.
121
+ * - `errored` — the row could not be read as a user at all. It is a defect in
122
+ * the file, and it is the only status whose rows an operator has to go and
123
+ * fix.
124
+ */
125
+ export const BulkUserRowStatus = {
126
+ CREATED: 'created',
127
+ SKIPPED: 'skipped',
128
+ ERRORED: 'errored',
129
+ };
130
+ export const BULK_USER_ROW_STATUS_VALUES = [
131
+ BulkUserRowStatus.CREATED,
132
+ BulkUserRowStatus.SKIPPED,
133
+ BulkUserRowStatus.ERRORED,
134
+ ];
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@plantops/contracts",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ "./package.json": "./package.json",
11
+ ".": {
12
+ "@plantops/source": "./src/index.ts",
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "nx": {
19
+ "tags": [
20
+ "type:lib",
21
+ "scope:contracts"
22
+ ]
23
+ },
24
+ "dependencies": {
25
+ "tslib": "^2.3.0"
26
+ },
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/dileepraghumajji/plant-ops.git",
31
+ "directory": "libs/contracts"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ]
39
+ }