@company-semantics/contracts 87.0.0 → 88.0.1

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.
@@ -0,0 +1,596 @@
1
+ /**
2
+ * Invite Schemas
3
+ *
4
+ * Org invites, the people directory, the deprecated one-shot bulk invite, and the
5
+ * persisted invite-batch (Prepare → Review → Send) lifecycle.
6
+ *
7
+ * Split out of `./schemas.ts`, which re-exports this module unchanged — import
8
+ * from `./schemas` (or the `./org` entry), not from this file directly.
9
+ */
10
+ import { z } from "zod";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Invite sub-schema
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Role the invitee takes in their home unit on acceptance. `unit_owner` is a
18
+ * deferred unit-ownership grant applied at accept time. Source of truth
19
+ * for the hand-mirrored `OrgInvite`/`CreateInviteRequest` interface unions in
20
+ * `org/types.ts` (enforced by the `schema-type-mirror` guard).
21
+ */
22
+ export const HomeUnitRoleSchema = z.enum(["member", "unit_owner"]);
23
+ export type HomeUnitRole = z.infer<typeof HomeUnitRoleSchema>;
24
+
25
+ /** Established title cap shared with org-unit/open-role titles. */
26
+ const STAGED_PLACEMENT_TITLE_MAX = 255;
27
+ /**
28
+ * Upper bound on the number of units an invitee can be staged into as owner or
29
+ * contributor. Bounds the replay cost of applying a staged placement at accept
30
+ * time (each entry is a deferred membership grant). A generous-but-finite cap.
31
+ */
32
+ const STAGED_PLACEMENT_UNITS_MAX = 64;
33
+
34
+ /**
35
+ * Staged member placement: the fuller org position an invitee is slotted into,
36
+ * applied as deferred grants when the invite is accepted. A superset of the
37
+ * legacy top-level `homeUnitId`/`homeUnitRole` fields, carrying the (optional)
38
+ * intended title, manager, and the units the invitee will own or contribute to.
39
+ *
40
+ * Every field is optional or defaulted so a placement can be partially
41
+ * specified at invite time. The array caps bound downstream replay cost.
42
+ */
43
+ export const StagedPlacementSchema = z.object({
44
+ // Intended job/role title for the invitee. Trimmed; non-empty when present.
45
+ title: z.string().trim().min(1).max(STAGED_PLACEMENT_TITLE_MAX).optional(),
46
+ // The invitee's manager on acceptance (a `manages` designation).
47
+ managerUserId: z.string().uuid().optional(),
48
+ // Home unit the invitee is placed in (their `users.primary_unit_id`).
49
+ homeUnitId: z.string().uuid().optional(),
50
+ // Role the invitee takes in `homeUnitId` ('unit_owner' = deferred grant).
51
+ homeUnitRole: HomeUnitRoleSchema.optional(),
52
+ // Additional units the invitee becomes a unit owner of on acceptance.
53
+ unitOwnerOf: z
54
+ .array(z.string().uuid())
55
+ .max(STAGED_PLACEMENT_UNITS_MAX)
56
+ .default([]),
57
+ // Units the invitee contributes to (non-owner membership) on acceptance.
58
+ contributesTo: z
59
+ .array(z.string().uuid())
60
+ .max(STAGED_PLACEMENT_UNITS_MAX)
61
+ .default([]),
62
+ });
63
+ export type StagedPlacement = z.infer<typeof StagedPlacementSchema>;
64
+
65
+ const OrgInviteSchema = z.object({
66
+ id: z.string(),
67
+ orgId: z.string(),
68
+ email: z.string(),
69
+ role: z.enum(["admin", "member"]),
70
+ invitedBy: z.object({ id: z.string(), name: z.string() }),
71
+ status: z.enum(["pending", "accepted", "expired", "revoked"]),
72
+ // Home unit the invitee is placed in on acceptance (users.primary_unit_id).
73
+ // Optional/nullable: legacy invites predate this field.
74
+ homeUnitId: z.string().uuid().optional(),
75
+ // Role the invitee takes in homeUnitId on acceptance ('unit_owner' is a
76
+ // deferred unit-ownership grant). Optional/'member' for legacy +
77
+ // unit-less invites.
78
+ homeUnitRole: HomeUnitRoleSchema.optional(),
79
+ // Fuller staged placement applied as deferred grants on acceptance. A
80
+ // superset of the top-level homeUnitId/homeUnitRole, kept additive/optional
81
+ // so legacy invites and unit-less invites parse unchanged.
82
+ stagedPlacement: StagedPlacementSchema.optional(),
83
+ createdAt: z.string(),
84
+ expiresAt: z.string(),
85
+ acceptedAt: z.string().optional(),
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // POST /api/workspace/invites
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export const InviteResponseSchema = z.object({
93
+ invite: OrgInviteSchema,
94
+ });
95
+
96
+ export type InviteResponse = z.infer<typeof InviteResponseSchema>;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // GET /api/workspace/invites
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export const InviteListResponseSchema = z.array(OrgInviteSchema);
103
+
104
+ export type InviteListResponse = z.infer<typeof InviteListResponseSchema>;
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // POST /api/workspace/invites — request body
108
+ // Request schema colocated with the response schema (narrow deviation from
109
+ // ADR-CONT-044, per the CreateOpenRoleRequestSchema precedent) because the
110
+ // staged-placement shape is the published promise consumers must satisfy.
111
+ // Mirrors the hand-written `CreateInviteRequest` interface in org/types.ts and
112
+ // extends it with the optional `stagedPlacement` superset.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export const CreateInviteRequestSchema = z.object({
116
+ email: z.string().email(),
117
+ // Invites only grant the restricted {admin, member} domain.
118
+ role: z.enum(["admin", "member"]),
119
+ // Home unit the invitee is placed in on acceptance. Optional in the contract
120
+ // so the field can roll out without breaking older callers.
121
+ homeUnitId: z.string().uuid().optional(),
122
+ // Role the invitee takes in homeUnitId — only meaningful with a home unit.
123
+ homeUnitRole: HomeUnitRoleSchema.optional(),
124
+ // Fuller staged placement (title, manager, owned/contributed units) applied
125
+ // as deferred grants on acceptance. Optional/additive for back-compat.
126
+ stagedPlacement: StagedPlacementSchema.optional(),
127
+ });
128
+
129
+ export type CreateInviteRequestPayload = z.infer<
130
+ typeof CreateInviteRequestSchema
131
+ >;
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // GET /api/workspace/directory
135
+ //
136
+ // The org's PEOPLE DIRECTORY — every `persons` row, account-bearing or not.
137
+ // Distinct from the member list (`user_org_memberships`, RBAC principals) and
138
+ // from the org chart (`positions` + occupancy, topology): this is the answer to
139
+ // "who does this org know about", which after an HRIS sync is a much larger set
140
+ // than either.
141
+ //
142
+ // It exists because inviting a synced person needs their address, and the org
143
+ // chart cannot carry one: `GET /api/users/org-chart` is authenticated-only, so a
144
+ // `primaryEmail` there would let any member enumerate the whole company's email
145
+ // addresses. This surface is capability-gated instead.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export const OrgDirectoryPersonSchema = z.object({
149
+ personId: z.string().uuid(),
150
+ displayName: z.string(),
151
+ /**
152
+ * Nullable and NOT optional. A directory person with no address is a real,
153
+ * common state — an HRIS export with the email column unmapped produces a
154
+ * whole org of them — and it is the difference between "cannot be invited" and
155
+ * "not yet loaded". Callers must render the distinction rather than treat a
156
+ * missing address as absence of the person.
157
+ */
158
+ primaryEmail: z.string().nullable(),
159
+ /** The linked account, or null when this person has never signed up. */
160
+ userId: z.string().uuid().nullable(),
161
+ });
162
+
163
+ export type OrgDirectoryPerson = z.infer<typeof OrgDirectoryPersonSchema>;
164
+
165
+ export const OrgDirectoryResponseSchema = z.object({
166
+ people: z.array(OrgDirectoryPersonSchema),
167
+ });
168
+
169
+ export type OrgDirectoryResponse = z.infer<typeof OrgDirectoryResponseSchema>;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // POST /api/workspace/invites/batch — DEPRECATED (removed in contracts 58.0.0)
173
+ //
174
+ // Superseded by the invite-batch lifecycle below. This route sends real email
175
+ // the instant it is called: there is no moment at which a human sees the
176
+ // addresses that are about to be written to, and no way to cancel. Everything it
177
+ // does correctly — personId keying, per-person transactions, three honest result
178
+ // buckets — is preserved by `POST /api/workspace/invite-batches/{id}/send`, with
179
+ // a review boundary in front of it.
180
+ //
181
+ // Kept live for one major so consumers can migrate against a green cascade: a
182
+ // single major that deleted these types while the app still imported them could
183
+ // not land, because the release cascade gates on each consumer's full CI tier.
184
+ // 57.0.0 adds the lifecycle; consumers move; 58.0.0 deletes everything below.
185
+ //
186
+ // Bulk invite, keyed by `personId` rather than by email. That is a security
187
+ // property, not a convenience: the single-invite route takes a caller-supplied
188
+ // address and can therefore reach any mailbox on the internet, which is why it
189
+ // carries a strict per-minute limit. This route's recipients are resolved
190
+ // server-side from the caller's own `persons` rows, so the reachable set is
191
+ // bounded by the org's own directory and the arbitrary-recipient risk is gone.
192
+ //
193
+ // Consequence for callers: the client never needs to hold an email address to
194
+ // send an invite, and an unknown or cross-org `personId` is silently omitted
195
+ // from the response rather than reported — reporting it would make this a
196
+ // membership oracle.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Cap on one batch. Bounds worst-case request latency, since the invite emails
201
+ * are sent before the response returns (the raw token exists only in memory and
202
+ * must not be parked in a queue).
203
+ */
204
+ export const BATCH_INVITE_PERSONS_MAX = 100;
205
+
206
+ /**
207
+ * How many days an invitation link stays redeemable.
208
+ *
209
+ * Shared vocabulary, not prose: the backend stamps `expires_at` from this when
210
+ * it mints an invite, and the review surface states the same window to the
211
+ * person about to send one. It lived in both places as a literal and drifted —
212
+ * the copy still promised seven days after the policy moved.
213
+ *
214
+ * A SECURITY parameter as much as a UX one: it bounds how long a leaked invite
215
+ * link stays usable, so widening it is a deliberate trust-model change rather
216
+ * than a copy edit.
217
+ */
218
+ export const INVITE_EXPIRY_DAYS = 30;
219
+
220
+ /** @deprecated Use {@link CreateInviteBatchRequestSchema}. Removed in 58.0.0. */
221
+ export const BatchInviteRequestSchema = z.object({
222
+ personIds: z.array(z.string().uuid()).min(1).max(BATCH_INVITE_PERSONS_MAX),
223
+ // Same restricted {admin, member} domain as a single invite.
224
+ role: z.enum(["admin", "member"]),
225
+ });
226
+
227
+ export type BatchInviteRequest = z.infer<typeof BatchInviteRequestSchema>;
228
+
229
+ /**
230
+ * Why one person in a batch did not receive an invite.
231
+ *
232
+ * - `already-member` — holds an account in this org already.
233
+ * - `invite-pending` — an unexpired invite is already outstanding.
234
+ * - `no-email` — the directory row carries no address to send to.
235
+ * - `send-failed` — the invite row COMMITTED but the email did not leave.
236
+ * Recoverable by resending; never a reason to roll the
237
+ * invite back.
238
+ */
239
+ export const BATCH_INVITE_SKIP_REASONS = [
240
+ "already-member",
241
+ "invite-pending",
242
+ "no-email",
243
+ "send-failed",
244
+ ] as const;
245
+
246
+ /** @deprecated Use {@link InviteBatchItemReasonSchema}. Removed in 58.0.0. */
247
+ export const BatchInviteSkipReasonSchema = z.enum(BATCH_INVITE_SKIP_REASONS);
248
+
249
+ export type BatchInviteSkipReason = z.infer<typeof BatchInviteSkipReasonSchema>;
250
+
251
+ const BatchInviteOutcomeSchema = z.object({
252
+ personId: z.string().uuid(),
253
+ reason: BatchInviteSkipReasonSchema,
254
+ });
255
+
256
+ /** @deprecated Use {@link InviteBatchResponseSchema}. Removed in 58.0.0. */
257
+ export const BatchInviteResponseSchema = z.object({
258
+ /** Person ids that now hold a pending invite AND were emailed. */
259
+ invited: z.array(z.string().uuid()),
260
+ /** Never attempted — see {@link BatchInviteSkipReason}. */
261
+ skipped: z.array(BatchInviteOutcomeSchema),
262
+ /**
263
+ * Invite row exists, delivery did not. Separate from `skipped` because the
264
+ * remedy differs: a skip needs the underlying state to change, a failure needs
265
+ * a resend.
266
+ */
267
+ failed: z.array(BatchInviteOutcomeSchema),
268
+ });
269
+
270
+ export type BatchInviteResponse = z.infer<typeof BatchInviteResponseSchema>;
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Invite batches — the Prepare → Review → Send lifecycle
274
+ //
275
+ // POST /api/workspace/invite-batches create a draft
276
+ // GET /api/workspace/invite-batches/{id} review it
277
+ // PATCH /api/workspace/invite-batches/{id} change selection/role (bumps revision)
278
+ // POST /api/workspace/invite-batches/{id}/prepare evaluate eligibility, draft → ready
279
+ // POST /api/workspace/invite-batches/{id}/send send, gated on expectedRevision
280
+ // POST /api/workspace/invite-batches/{id}/cancel cancel what has not been sent
281
+ //
282
+ // WHY A PERSISTED BATCH AT ALL. The one-shot bulk route (`POST
283
+ // /api/workspace/invites/batch`, deprecated by this vocabulary) sends real email
284
+ // the instant it is called — there is no moment at which a human can see the 88
285
+ // addresses that are about to be written to. This lifecycle inserts exactly one:
286
+ // `prepare` decides who is eligible, the review surface shows the recipients and
287
+ // their addresses, and `send` acts on what was reviewed or refuses.
288
+ //
289
+ // THE BATCH HOLDS INTENT, NEVER CREDENTIALS. There is no token field on a batch
290
+ // or an item, in any form, and there never may be. An invite token exists only
291
+ // between generation and the send, in memory; only its SHA-256 is stored, on the
292
+ // invite row. A batch that parked a live token at rest would undo that whole
293
+ // property in exchange for nothing — the item carries `inviteId`, which is the
294
+ // durable handle to the credential without being one.
295
+ //
296
+ // CONFIRMATION AUTHORIZES THE ADDRESSES SHOWN AT REVIEW TIME. `emailSnapshot` is
297
+ // what the reviewer saw. At send, an item whose person now holds a different
298
+ // address is `skipped` with `address-changed` — never mailed at the new one. The
299
+ // same is true of someone who became a member, or gained an invite by another
300
+ // route, between review and send. Approving a list is not approving whatever
301
+ // that list becomes.
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /**
305
+ * Batch lifecycle.
306
+ *
307
+ * - `draft` — created, membership not yet evaluated. Sends nothing.
308
+ * - `ready` — `prepare` ran; the item set and its exclusions are the review.
309
+ * - `sending` — CLAIMED by a send. See {@link SendInviteBatchRequestSchema}.
310
+ * - `completed` — the send finished. Per-person outcomes live on the items.
311
+ * - `canceled` — canceled before or between sends; unsent items stay unsent.
312
+ *
313
+ * There is deliberately no `partially_completed`: it is `failedCount > 0` on a
314
+ * `completed` batch, and a second spelling of one fact is a second thing to keep
315
+ * true. There is deliberately no `expired` either — nothing sweeps batches, and
316
+ * a status no writer ever writes is a promise the API does not keep.
317
+ */
318
+ export const INVITE_BATCH_STATUSES = [
319
+ "draft",
320
+ "ready",
321
+ "sending",
322
+ "completed",
323
+ "canceled",
324
+ ] as const;
325
+
326
+ export const InviteBatchStatusSchema = z.enum(INVITE_BATCH_STATUSES);
327
+
328
+ export type InviteBatchStatus = z.infer<typeof InviteBatchStatusSchema>;
329
+
330
+ /**
331
+ * Per-recipient outcome within a batch.
332
+ *
333
+ * - `eligible` — will be invited when the batch is sent.
334
+ * - `excluded` — will not, decided at PREPARE time. See {@link InviteBatchItemReason}.
335
+ * - `sent` — an invite row exists AND the email left. Carries `inviteId`.
336
+ * - `failed` — an invite row exists, the email did NOT leave. Also carries
337
+ * `inviteId`: the remedy is a reissue, never a re-send, because
338
+ * the credential in that row was already minted.
339
+ * - `skipped` — eligible at review, refused at SEND because the world moved.
340
+ *
341
+ * `eligible` items on a batch stuck in `sending` are exactly the ones a crashed
342
+ * send never reached — which is why there is no separate `pending_send`.
343
+ */
344
+ export const INVITE_BATCH_ITEM_STATUSES = [
345
+ "eligible",
346
+ "excluded",
347
+ "sent",
348
+ "failed",
349
+ "skipped",
350
+ ] as const;
351
+
352
+ export const InviteBatchItemStatusSchema = z.enum(INVITE_BATCH_ITEM_STATUSES);
353
+
354
+ export type InviteBatchItemStatus = z.infer<typeof InviteBatchItemStatusSchema>;
355
+
356
+ /**
357
+ * Why an item is not `sent`. One vocabulary across `excluded` (decided at
358
+ * prepare) and `skipped` (decided at send) — the reader wants "why did this
359
+ * person not get an invitation", and splitting the answer by which stage
360
+ * noticed would make that two questions.
361
+ *
362
+ * - `already-member` — holds an account in this org.
363
+ * - `invite-pending` — an unexpired invite is already outstanding.
364
+ * - `no-email` — the directory row carries no address.
365
+ * - `invalid-email` — it carries one that is not a deliverable address.
366
+ * An HRIS export with a mangled column produces these;
367
+ * committing an invite row that can never be mailed is
368
+ * the failure this reason exists to prevent.
369
+ * - `removed-by-reviewer` — a human un-ticked them. Persisted rather than
370
+ * deleted so the choice is reversible AND legible:
371
+ * "who was deliberately left out" is a question the
372
+ * batch must be able to answer later.
373
+ * - `address-changed` — SEND-time only. The person's address is no longer
374
+ * the one shown at review. Confirmation authorized the
375
+ * address, not the person.
376
+ * - `person-removed` — SEND-time only. The directory row is gone.
377
+ * - `send-failed` — pairs with `failed`, not with `excluded`/`skipped`.
378
+ * - `inactive-in-hris` — RESERVED, and NOTHING WRITES IT TODAY. `persons`
379
+ * carries no employment-status column, the BambooHR
380
+ * client never reads one, and a "Sync now" full resync
381
+ * reports no departures at all — so a terminated
382
+ * employee is currently indistinguishable from a
383
+ * current one. The name is held here so the eventual
384
+ * implementation does not have to change the wire
385
+ * vocabulary; treating its ABSENCE as evidence that
386
+ * everyone listed is still employed would be wrong.
387
+ */
388
+ export const INVITE_BATCH_ITEM_REASONS = [
389
+ "already-member",
390
+ "invite-pending",
391
+ "no-email",
392
+ "invalid-email",
393
+ "removed-by-reviewer",
394
+ "address-changed",
395
+ "person-removed",
396
+ "send-failed",
397
+ "inactive-in-hris",
398
+ ] as const;
399
+
400
+ export const InviteBatchItemReasonSchema = z.enum(INVITE_BATCH_ITEM_REASONS);
401
+
402
+ export type InviteBatchItemReason = z.infer<typeof InviteBatchItemReasonSchema>;
403
+
404
+ export const InviteBatchItemSchema = z.object({
405
+ personId: z.string().uuid(),
406
+ /**
407
+ * Read live from the directory, NOT snapshotted. A name is for recognizing the
408
+ * human; only the ADDRESS is the thing confirmation authorizes, so only the
409
+ * address is frozen. Null when the directory row has since been deleted.
410
+ */
411
+ displayName: z.string().nullable(),
412
+ /**
413
+ * The address as it stood when `prepare` ran — what the reviewer approved.
414
+ * Null only when the person had none. Compared against the live address at
415
+ * send; a mismatch is `address-changed`, never a send to the new one.
416
+ */
417
+ emailSnapshot: z.string().nullable(),
418
+ role: z.enum(["admin", "member"]),
419
+ status: InviteBatchItemStatusSchema,
420
+ /** Null exactly when `status` is `eligible` or `sent`. */
421
+ reason: InviteBatchItemReasonSchema.nullable(),
422
+ /**
423
+ * The invite row this item produced. Present for `sent` AND for `failed` — a
424
+ * failed item's credential was minted and committed before the send was
425
+ * attempted, so the row exists and reissue is the remedy.
426
+ */
427
+ inviteId: z.string().uuid().nullable(),
428
+ /**
429
+ * The snapshot address is not under a domain this org has VERIFIED. Computed
430
+ * at read time from `org_domains` (status `verified`), never stored: a domain
431
+ * verified after review must stop warning, and one revoked after review must
432
+ * start. Advisory — it does not exclude anyone. Sending outside your own
433
+ * domains is legitimate (contractors, personal addresses); doing it eighty-
434
+ * eight times without noticing is the accident worth surfacing.
435
+ */
436
+ emailOutsideVerifiedDomain: z.boolean(),
437
+ });
438
+
439
+ export type InviteBatchItem = z.infer<typeof InviteBatchItemSchema>;
440
+
441
+ /**
442
+ * Denormalized counters, maintained by the batch's own writers in the same
443
+ * transaction as the items they describe.
444
+ *
445
+ * DELIBERATELY NOT DERIVED CLIENT-SIDE. The confirm control reads
446
+ * "Send {eligible} invitations", and that number must be the number the SERVER
447
+ * will act on. A count computed from a page of items can disagree with the set
448
+ * `send` iterates; a count the server wrote cannot.
449
+ */
450
+ export const InviteBatchCountsSchema = z.object({
451
+ /** Every person the batch was created for. Never changes after create. */
452
+ candidate: z.number().int().nonnegative(),
453
+ eligible: z.number().int().nonnegative(),
454
+ excluded: z.number().int().nonnegative(),
455
+ sent: z.number().int().nonnegative(),
456
+ failed: z.number().int().nonnegative(),
457
+ skipped: z.number().int().nonnegative(),
458
+ });
459
+
460
+ export type InviteBatchCounts = z.infer<typeof InviteBatchCountsSchema>;
461
+
462
+ export const InviteBatchSchema = z.object({
463
+ id: z.string().uuid(),
464
+ status: InviteBatchStatusSchema,
465
+ /**
466
+ * Where the candidate set came from. OPEN string, `directory` today — the org
467
+ * people directory. Not an enum: a future CSV or SCIM origin should not need a
468
+ * migration to be nameable.
469
+ */
470
+ source: z.string(),
471
+ /**
472
+ * Bumped by every mutation of the reviewable set (selection or role). `send`
473
+ * carries the revision the reviewer saw; a mismatch is
474
+ * {@link INVITE_BATCH_CHANGED} rather than a send of something else.
475
+ */
476
+ revision: z.number().int().nonnegative(),
477
+ counts: InviteBatchCountsSchema,
478
+ createdByUserId: z.string().uuid(),
479
+ /** Who ran `send`, and when they did. Null until then. */
480
+ confirmedByUserId: z.string().uuid().nullable(),
481
+ confirmedAt: z.string().nullable(),
482
+ createdAt: z.string(),
483
+ updatedAt: z.string(),
484
+ });
485
+
486
+ export type InviteBatch = z.infer<typeof InviteBatchSchema>;
487
+
488
+ export const InviteBatchResponseSchema = z.object({
489
+ batch: InviteBatchSchema,
490
+ items: z.array(InviteBatchItemSchema),
491
+ /**
492
+ * The org's verified domains, so the review surface can NAME them in the
493
+ * out-of-domain warning rather than just flagging rows. Empty is the common
494
+ * case and means every address is out-of-domain by this definition — render
495
+ * the warning accordingly rather than as eighty-eight alarms.
496
+ */
497
+ verifiedDomains: z.array(z.string()),
498
+ });
499
+
500
+ export type InviteBatchResponse = z.infer<typeof InviteBatchResponseSchema>;
501
+
502
+ export const CreateInviteBatchRequestSchema = z.object({
503
+ personIds: z.array(z.string().uuid()).min(1).max(BATCH_INVITE_PERSONS_MAX),
504
+ role: z.enum(["admin", "member"]),
505
+ });
506
+
507
+ export type CreateInviteBatchRequest = z.infer<
508
+ typeof CreateInviteBatchRequestSchema
509
+ >;
510
+
511
+ /**
512
+ * Change what a `draft` or `ready` batch will do. Every accepted call bumps
513
+ * `revision`, which is what invalidates a review already on someone's screen.
514
+ *
515
+ * Deselecting does NOT delete the item — it becomes `excluded` with
516
+ * `removed-by-reviewer`, so reselecting is a status flip rather than a
517
+ * re-preparation, and the batch can still answer who was deliberately omitted.
518
+ * Reselecting a person excluded by POLICY (already a member, no address) is
519
+ * refused: `reselect` reverses a human's decision, not the server's.
520
+ */
521
+ export const UpdateInviteBatchRequestSchema = z
522
+ .object({
523
+ deselectPersonIds: z.array(z.string().uuid()).optional(),
524
+ reselectPersonIds: z.array(z.string().uuid()).optional(),
525
+ role: z.enum(["admin", "member"]).optional(),
526
+ })
527
+ .refine(
528
+ (v) =>
529
+ v.deselectPersonIds !== undefined ||
530
+ v.reselectPersonIds !== undefined ||
531
+ v.role !== undefined,
532
+ {
533
+ message:
534
+ "Provide at least one of deselectPersonIds, reselectPersonIds, role",
535
+ },
536
+ );
537
+
538
+ export type UpdateInviteBatchRequest = z.infer<
539
+ typeof UpdateInviteBatchRequestSchema
540
+ >;
541
+
542
+ /**
543
+ * `expectedRevision` is REQUIRED and has no default. Sending is the only
544
+ * irreversible verb in this lifecycle — email cannot be recalled — so the one
545
+ * call that performs it must state what it believes it is sending.
546
+ *
547
+ * There is no `Idempotency-Key` here, deliberately. The send claim
548
+ * (`UPDATE ... SET status='sending' WHERE status='ready' AND revision = ?`) is
549
+ * an atomic compare-and-set: the first caller wins and every later one sees zero
550
+ * rows and gets {@link INVITE_BATCH_CHANGED}. That is exactly-once permanently
551
+ * and across every task in the cluster, where an idempotency key would be
552
+ * exactly-once for its retention window and only where the key store is shared.
553
+ * Routing this through a key ALSO means wrapping check + mutation + store in one
554
+ * transaction, which would roll back the record of mail that had already left.
555
+ */
556
+ export const SendInviteBatchRequestSchema = z.object({
557
+ expectedRevision: z.number().int().nonnegative(),
558
+ /**
559
+ * The reviewer's optional note, carried into every invitation this send mails
560
+ * and attributed to the inviter (`NotificationPayloads['org.invite'].message`).
561
+ *
562
+ * It rides the SEND rather than the batch on purpose: it authorizes nothing,
563
+ * so it has no business bumping a revision that every open review reads as
564
+ * "read this again". Not persisted — the invitation is the only thing that
565
+ * ever carries it.
566
+ */
567
+ message: z.string().max(2000).optional(),
568
+ /**
569
+ * Per-person role overrides, keyed by `personId` — the reviewer set a role for
570
+ * ONE recipient rather than for the batch.
571
+ *
572
+ * The batch's own `role` remains what everyone else is invited as; a key here
573
+ * replaces it for that person only. A key naming somebody outside the batch,
574
+ * or somebody it is no longer sending to, is INERT rather than an error: the
575
+ * live directory re-check at send time already decides who is mailed, and a
576
+ * stale override describes a person nobody is inviting.
577
+ */
578
+ roleByPersonId: z
579
+ .record(z.string().uuid(), z.enum(["admin", "member"]))
580
+ .optional(),
581
+ });
582
+
583
+ export type SendInviteBatchRequest = z.infer<
584
+ typeof SendInviteBatchRequestSchema
585
+ >;
586
+
587
+ /**
588
+ * The error code a stale `send` (or a mutation of an already-claimed batch)
589
+ * returns, as `error` on the standard 409 envelope.
590
+ *
591
+ * It means: what you reviewed is not what is here now. The client's obligation
592
+ * is to re-fetch and put the CHANGES in front of the human again before the
593
+ * confirm control re-arms — not to retry with the new revision, which would
594
+ * authorize a list nobody read.
595
+ */
596
+ export const INVITE_BATCH_CHANGED = "BATCH_CHANGED" as const;