@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,198 @@
1
+ /**
2
+ * Org Governance Response Schemas
3
+ *
4
+ * Org domains, deletion eligibility, ownership transfer, system events and the
5
+ * org AI-usage report.
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
+ import { IsoDateTime } from "../api/primitives";
12
+ import { MoneyMicrosSchema } from "../billing/vocabulary";
13
+ import { AI_FEATURES } from "../usage/features";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // OrgDomain sub-schema
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const OrgDomainSchema = z.object({
20
+ id: z.string(),
21
+ orgId: z.string(),
22
+ domain: z.string(),
23
+ status: z.enum(["pending", "verified"]),
24
+ verificationMethod: z.enum(["dns_txt", "email", "idp"]),
25
+ verificationToken: z.string().optional(),
26
+ verifiedAt: z.string().nullable(),
27
+ createdAt: z.string(),
28
+ verifiedBy: z
29
+ .object({ id: z.string(), name: z.string(), email: z.string() })
30
+ .optional(),
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // GET /api/workspace/domains/:id/verify (single domain)
35
+ // POST /api/workspace/domains/:id/verify (returns verified OrgDomain)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export const DomainResponseSchema = OrgDomainSchema;
39
+
40
+ export type DomainResponse = z.infer<typeof DomainResponseSchema>;
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // GET /api/workspace/domains
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export const DomainListResponseSchema = z.array(OrgDomainSchema);
47
+
48
+ export type DomainListResponse = z.infer<typeof DomainListResponseSchema>;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // GET /api/org/deletion-eligibility
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const MemberClassificationSchema = z.object({
55
+ userId: z.string(),
56
+ email: z.string(),
57
+ suggestedFate: z.enum(["REMOVE_FROM_ORG", "DELETE_ACCOUNT", "BLOCKED"]),
58
+ reason: z.string(),
59
+ });
60
+
61
+ export const OrgDeletionStatusSchema = z.object({
62
+ orgId: z.string(),
63
+ eligible: z.boolean(),
64
+ isPersonalOrg: z.boolean(),
65
+ ownerStatus: z.string().optional(),
66
+ deletionRequestedAt: z.string().optional(),
67
+ classifications: z.array(MemberClassificationSchema),
68
+ });
69
+
70
+ export type OrgDeletionStatus = z.infer<typeof OrgDeletionStatusSchema>;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // POST /api/org/transfer-ownership (initiate)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export const OwnershipTransferResponseSchema = z.object({
77
+ transferId: z.string(),
78
+ expiresAt: z.string(),
79
+ message: z.string(),
80
+ });
81
+
82
+ export type OwnershipTransferResponse = z.infer<
83
+ typeof OwnershipTransferResponseSchema
84
+ >;
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // POST /api/org/transfer-ownership/preview
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export const OwnershipTransferPreviewSchema = z.object({
91
+ workspaceName: z.string(),
92
+ initiatorName: z.string(),
93
+ initiatorEmail: z.string(),
94
+ note: z.string().optional(),
95
+ expiresAt: z.string(),
96
+ token: z.string(),
97
+ });
98
+
99
+ export type OwnershipTransferPreview = z.infer<
100
+ typeof OwnershipTransferPreviewSchema
101
+ >;
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // GET /api/org/system-events
105
+ // ---------------------------------------------------------------------------
106
+
107
+ const OrgSystemEventSchema = z.object({
108
+ id: z.string(),
109
+ type: z.string(),
110
+ payload: z.unknown(),
111
+ createdAt: z.string(),
112
+ });
113
+
114
+ export const OrgSystemEventsListSchema = z.object({
115
+ events: z.array(OrgSystemEventSchema),
116
+ });
117
+
118
+ export type OrgSystemEventsList = z.infer<typeof OrgSystemEventsListSchema>;
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // POST /api/org/system-events/:id/acknowledge
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export const AcknowledgeSystemEventResponseSchema = z.object({
125
+ success: z.literal(true),
126
+ });
127
+
128
+ export type AcknowledgeSystemEventResponse = z.infer<
129
+ typeof AcknowledgeSystemEventResponseSchema
130
+ >;
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // GET /api/orgs/:orgId/ai-usage
134
+ //
135
+ // INV-USAGE-LEDGER: every request, token and dollar here is read from the
136
+ // settled billing ledger (`billing_usage_events`) — one row per billed provider
137
+ // call, whatever feature made it. A `request` is one settled billable provider
138
+ // invocation (retries and fallbacks are separate requests), every amount is
139
+ // the customer charge in integer micro-USD, and `tokens` is input + output +
140
+ // cache read + cache write (reasoning is a subset of output, never added).
141
+ // `chatRuns` alone comes from runtime telemetry: duration and failure
142
+ // diagnostics for chat turns, carrying no money.
143
+ // ---------------------------------------------------------------------------
144
+
145
+ const OrgAiUsageTotalsSchema = z.object({
146
+ requests: z.number().int().min(0),
147
+ tokens: z.number().int().min(0),
148
+ customerChargeMicros: MoneyMicrosSchema,
149
+ });
150
+
151
+ const OrgChatRunProfileSchema = z.object({
152
+ profile: z.string(),
153
+ executions: z.number().int().min(0),
154
+ percentOfTotal: z.number(),
155
+ avgDurationMs: z.number(),
156
+ failureRate: z.number(),
157
+ });
158
+
159
+ export const OrgUsageResponseSchema = z.object({
160
+ /** The org's billing window: `start`/`end` in UTC, days bucketed in `timezone`. */
161
+ period: z.object({
162
+ start: IsoDateTime,
163
+ end: IsoDateTime,
164
+ timezone: z.string(),
165
+ }),
166
+ summary: OrgAiUsageTotalsSchema,
167
+ daily: z.array(
168
+ z.object({
169
+ date: z.string(),
170
+ requests: z.number().int().min(0),
171
+ customerChargeMicros: MoneyMicrosSchema,
172
+ }),
173
+ ),
174
+ byFeature: z.array(
175
+ OrgAiUsageTotalsSchema.extend({ feature: z.enum(AI_FEATURES) }),
176
+ ),
177
+ byModel: z.array(
178
+ OrgAiUsageTotalsSchema.extend({
179
+ provider: z.string(),
180
+ modelId: z.string(),
181
+ }),
182
+ ),
183
+ /** Attributed usage only: system work (no user) counts in the totals, not here. */
184
+ byUser: z.array(
185
+ OrgAiUsageTotalsSchema.extend({
186
+ userId: z.string(),
187
+ email: z.string().nullable(),
188
+ }),
189
+ ),
190
+ chatRuns: z.object({
191
+ executions: z.number().int().min(0),
192
+ avgDurationMs: z.number(),
193
+ failureRate: z.number(),
194
+ byProfile: z.array(OrgChatRunProfileSchema),
195
+ }),
196
+ });
197
+
198
+ export type OrgUsageResponse = z.infer<typeof OrgUsageResponseSchema>;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Org Unit API Schemas
3
+ *
4
+ * Unit relationships and level config, the org-unit response envelopes, open-role
5
+ * and unit-permission request/response schemas, and their inferred types.
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
+ import { ORG_UNIT_ABSOLUTE_MAX_DEPTH } from "./org-depth";
12
+ import {
13
+ MissingAtNextLevelSchema,
14
+ OpenRoleSchema,
15
+ OrgUnitMembershipSchema,
16
+ OrgUnitRelationshipRoleSchema,
17
+ OrgUnitRelationshipTypeSchema,
18
+ OrgUnitSchema,
19
+ OrgUnitTreeNodeSchema,
20
+ UnitMembershipRoleSchema,
21
+ } from "./org-unit-schemas";
22
+
23
+ export const OrgUnitRelationshipSchema = z.object({
24
+ id: z.string().uuid(),
25
+ orgId: z.string().uuid(),
26
+ fromUnit: z.string().uuid(),
27
+ toUnit: z.string().uuid(),
28
+ type: OrgUnitRelationshipTypeSchema,
29
+ role: OrgUnitRelationshipRoleSchema,
30
+ contextKey: z.string().nullable(),
31
+ metadata: z.record(z.string(), z.unknown()),
32
+ createdAt: z.string(),
33
+ });
34
+
35
+ export const OrgLevelIconSchema = z.enum([
36
+ "city",
37
+ "buildings",
38
+ "users-four",
39
+ "users-three",
40
+ "users",
41
+ ]);
42
+
43
+ export const OrgLevelConfigSchema = z.object({
44
+ orgId: z.string().uuid(),
45
+ depth: z.number().int().min(1).max(ORG_UNIT_ABSOLUTE_MAX_DEPTH),
46
+ label: z.string().min(1).nullable(),
47
+ labelPlural: z.string().min(1).nullable(),
48
+ icon: OrgLevelIconSchema.nullable(),
49
+ createdAt: z.string(),
50
+ updatedAt: z.string(),
51
+ });
52
+
53
+ // --- Response schemas (request schemas live in backend per ADR-CONT-044) ---
54
+
55
+ export const OrgUnitResponseSchema = z.object({ unit: OrgUnitSchema });
56
+
57
+ export const OrgUnitTreeResponseSchema = z.object({
58
+ nodes: z.array(OrgUnitTreeNodeSchema),
59
+ levelConfig: z.array(OrgLevelConfigSchema),
60
+ });
61
+
62
+ export const OrgUnitChildrenResponseSchema = z.object({
63
+ parentId: z.string().uuid(),
64
+ children: z.array(OrgUnitTreeNodeSchema),
65
+ });
66
+
67
+ export const OrgUnitAncestorsResponseSchema = z.object({
68
+ unitId: z.string().uuid(),
69
+ ancestors: z.array(OrgUnitSchema),
70
+ });
71
+
72
+ export const OrgUnitDescendantsResponseSchema = z.object({
73
+ rootId: z.string().uuid(),
74
+ descendants: z.array(OrgUnitSchema),
75
+ });
76
+
77
+ export const OrgUnitRelationshipsResponseSchema = z.object({
78
+ unitId: z.string().uuid(),
79
+ incoming: z.array(OrgUnitRelationshipSchema),
80
+ outgoing: z.array(OrgUnitRelationshipSchema),
81
+ });
82
+
83
+ export const OrgLevelConfigResponseSchema = z.object({
84
+ entries: z.array(OrgLevelConfigSchema),
85
+ });
86
+
87
+ export const OrgUnitMembershipResponseSchema = z.object({
88
+ membership: OrgUnitMembershipSchema,
89
+ });
90
+
91
+ export const OrgUnitMembershipListResponseSchema = z.object({
92
+ unitId: z.string().uuid(),
93
+ memberships: z.array(OrgUnitMembershipSchema),
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Open roles (PRD open-roles)
98
+ // Request schemas are colocated here (a narrow deviation from ADR-CONT-044, as
99
+ // with UpdateOrgUnitRequest) so the app's add-role form and the backend
100
+ // validate against one published shape.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export const OpenRoleResponseSchema = z.object({ openRole: OpenRoleSchema });
104
+
105
+ export const OpenRoleListResponseSchema = z.object({
106
+ unitId: z.string().uuid(),
107
+ openRoles: z.array(OpenRoleSchema),
108
+ });
109
+
110
+ /** ISO calendar date (YYYY-MM-DD) — version-agnostic vs `z.string().date()`. */
111
+ const IsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
112
+ message: "must be an ISO date (YYYY-MM-DD)",
113
+ });
114
+
115
+ export const CreateOpenRoleRequestSchema = z.object({
116
+ title: z.string().min(1).max(255).nullable().optional(),
117
+ targetStartDate: IsoDateSchema.nullable().optional(),
118
+ });
119
+
120
+ /** Only the non-terminal client transitions are accepted; `fill` is its own
121
+ * endpoint and `open` is the seeded initial state, never set by the client. */
122
+ export const UpdateOpenRoleStatusRequestSchema = z.object({
123
+ status: z.enum(["hiring", "closed"]),
124
+ });
125
+
126
+ /**
127
+ * Fill an open role by seating an existing org member (`userId`) or inviting a
128
+ * new person by email. Exactly one branch — a discriminated union keyed on `mode`.
129
+ */
130
+ export const FillOpenRoleRequestSchema = z.discriminatedUnion("mode", [
131
+ z.object({ mode: z.literal("existing"), userId: z.string().uuid() }),
132
+ z.object({
133
+ mode: z.literal("invite"),
134
+ email: z.string().email(),
135
+ role: z.enum(["admin", "member"]),
136
+ }),
137
+ ]);
138
+
139
+ /**
140
+ * Effective manager of an org unit — a user whose membership (direct or
141
+ * inherited from an ancestor) grants the `orgUnit.{id}.manage` capability.
142
+ *
143
+ * Direct grants have `inheritedFromUnitId === unitId`. For inherited grants,
144
+ * `inheritedFromUnitId` identifies the ancestor whose membership propagates
145
+ * authority down the subtree (ltree `path @>` containment).
146
+ */
147
+ export const OrgUnitPermissionsEntrySchema = z.object({
148
+ userId: z.string().uuid(),
149
+ membershipRole: UnitMembershipRoleSchema,
150
+ inheritedFromUnitId: z.string().uuid(),
151
+ inheritedFromUnitName: z.string().min(1),
152
+ });
153
+
154
+ export const ListOrgUnitPermissionsResponseSchema = z.object({
155
+ unitId: z.string().uuid(),
156
+ entries: z.array(OrgUnitPermissionsEntrySchema),
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // PATCH /api/org-units/:unitId (PRD-00526)
161
+ // Request schema colocated with the response schema so the app can import
162
+ // the same shape backend validates against. This is a narrow deviation from
163
+ // ADR-CONT-044 (request schemas normally live in backend) because the
164
+ // partial-update + at-least-one-field refinement is the published promise
165
+ // consumers must satisfy.
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export const UpdateOrgUnitRequestSchema = z
169
+ .object({
170
+ name: z.string().min(1).max(255).optional(),
171
+ description: z.string().max(2000).nullable().optional(),
172
+ })
173
+ .refine((v) => v.name !== undefined || v.description !== undefined, {
174
+ message: "at least one of name or description must be provided",
175
+ });
176
+
177
+ export const UpdateOrgUnitResponseSchema = z.object({ unit: OrgUnitSchema });
178
+
179
+ // --- Inferred types ---
180
+
181
+ export type OrgUnit = z.infer<typeof OrgUnitSchema>;
182
+ export type OrgUnitTreeNode = z.infer<typeof OrgUnitTreeNodeSchema>;
183
+ export type OrgUnitMembership = z.infer<typeof OrgUnitMembershipSchema>;
184
+ export type OrgUnitRelationship = z.infer<typeof OrgUnitRelationshipSchema>;
185
+ export type OrgLevelConfig = z.infer<typeof OrgLevelConfigSchema>;
186
+ export type OrgLevelIcon = z.infer<typeof OrgLevelIconSchema>;
187
+ export type OrgUnitResponse = z.infer<typeof OrgUnitResponseSchema>;
188
+ export type OrgUnitTreeResponse = z.infer<typeof OrgUnitTreeResponseSchema>;
189
+ export type MissingAtNextLevel = z.infer<typeof MissingAtNextLevelSchema>;
190
+ export type OrgUnitChildrenResponse = z.infer<
191
+ typeof OrgUnitChildrenResponseSchema
192
+ >;
193
+ export type OrgUnitAncestorsResponse = z.infer<
194
+ typeof OrgUnitAncestorsResponseSchema
195
+ >;
196
+ export type OrgUnitDescendantsResponse = z.infer<
197
+ typeof OrgUnitDescendantsResponseSchema
198
+ >;
199
+ export type OrgUnitRelationshipsResponse = z.infer<
200
+ typeof OrgUnitRelationshipsResponseSchema
201
+ >;
202
+ export type OrgLevelConfigResponse = z.infer<
203
+ typeof OrgLevelConfigResponseSchema
204
+ >;
205
+ export type OrgUnitMembershipResponse = z.infer<
206
+ typeof OrgUnitMembershipResponseSchema
207
+ >;
208
+ export type OrgUnitMembershipListResponse = z.infer<
209
+ typeof OrgUnitMembershipListResponseSchema
210
+ >;
211
+ export type OpenRole = z.infer<typeof OpenRoleSchema>;
212
+ export type OpenRoleResponse = z.infer<typeof OpenRoleResponseSchema>;
213
+ export type OpenRoleListResponse = z.infer<typeof OpenRoleListResponseSchema>;
214
+ export type CreateOpenRoleRequest = z.infer<typeof CreateOpenRoleRequestSchema>;
215
+ export type UpdateOpenRoleStatusRequest = z.infer<
216
+ typeof UpdateOpenRoleStatusRequestSchema
217
+ >;
218
+ export type FillOpenRoleRequest = z.infer<typeof FillOpenRoleRequestSchema>;
219
+ export type OrgUnitPermissionsEntry = z.infer<
220
+ typeof OrgUnitPermissionsEntrySchema
221
+ >;
222
+ export type ListOrgUnitPermissionsResponse = z.infer<
223
+ typeof ListOrgUnitPermissionsResponseSchema
224
+ >;
225
+ export type UpdateOrgUnitRequest = z.infer<typeof UpdateOrgUnitRequestSchema>;
226
+ export type UpdateOrgUnitResponse = z.infer<typeof UpdateOrgUnitResponseSchema>;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Org Unit Schemas
3
+ *
4
+ * The canonical org-unit model (ADR-BE-120): unit vocabulary enums, the unit and
5
+ * tree-node shapes, memberships and open roles.
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
+ import {
12
+ ORG_UNIT_ABSOLUTE_MAX_DEPTH,
13
+ UNIT_OWNER_MEMBERSHIP_ROLES,
14
+ } from "./org-depth";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // OrgUnit canonical model (ADR-BE-120)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export const OrgUnitClassificationSchema = z.enum([
21
+ "execution_unit",
22
+ "org_container",
23
+ "leadership",
24
+ "custom",
25
+ ]);
26
+
27
+ export const OrgUnitSyncModeSchema = z.enum([
28
+ "manual_only",
29
+ "synced_readonly",
30
+ "synced_with_overrides",
31
+ ]);
32
+
33
+ export const OrgUnitVisibilitySchema = z.enum([
34
+ "public",
35
+ "private",
36
+ "restricted",
37
+ ]);
38
+
39
+ export const OrgUnitRelationshipTypeSchema = z.enum([
40
+ "collaborates_with",
41
+ "reports_to",
42
+ "depends_on",
43
+ "custom",
44
+ ]);
45
+
46
+ export const OrgUnitRelationshipRoleSchema = z.enum([
47
+ "owner",
48
+ "participant",
49
+ "supporting",
50
+ ]);
51
+
52
+ /**
53
+ * Positional slot a member holds in the org-unit tree. ORTHOGONAL to the
54
+ * `OrgChartRole` policy vocabulary (this is an INPUT to authority resolution,
55
+ * not the derived policy role). Level is encoded in the value (l1…l20, one
56
+ * per depth up to `ORG_UNIT_ABSOLUTE_MAX_DEPTH`).
57
+ */
58
+ export const UnitMembershipRoleSchema = z.enum([
59
+ "member",
60
+ ...UNIT_OWNER_MEMBERSHIP_ROLES,
61
+ ]);
62
+
63
+ export type UnitMembershipRole = z.infer<typeof UnitMembershipRoleSchema>;
64
+
65
+ export const OrgUnitMembershipStatusSchema = z.enum([
66
+ "active",
67
+ "pending",
68
+ "removed",
69
+ ]);
70
+
71
+ export const OrgUnitMembershipSourceSchema = z.enum([
72
+ "manual",
73
+ "google_groups",
74
+ "scim",
75
+ "hris",
76
+ ]);
77
+
78
+ /**
79
+ * Hiring lifecycle of an open role. See `OpenRoleStatus` in `./org-units` for
80
+ * the transition rules. Only `open` and `hiring` are "active" (render a card
81
+ * and count toward `openRoleCount`); `filled`/`closed` are terminal.
82
+ */
83
+ export const OpenRoleStatusSchema = z.enum([
84
+ "open",
85
+ "hiring",
86
+ "filled",
87
+ "closed",
88
+ ]);
89
+
90
+ export const OrgUnitErrorCodeSchema = z.enum([
91
+ "CYCLE_BLOCKED",
92
+ "DEPTH_EXCEEDED",
93
+ "TARGET_ARCHIVED",
94
+ "ROOT_HAS_NO_PARENT",
95
+ "PARENT_ARCHIVED",
96
+ "SLUG_TAKEN",
97
+ "CROSS_ORG_REPARENT",
98
+ "ORDERKEY_CONFLICT",
99
+ "SIBLING_NOT_FOUND",
100
+ "SIBLINGS_DIFFERENT_PARENT",
101
+ ]);
102
+
103
+ export const OrgUnitSchema = z.object({
104
+ id: z.string().uuid(),
105
+ orgId: z.string().uuid(),
106
+ parentId: z.string().uuid().nullable(),
107
+ slug: z.string().min(1),
108
+ name: z.string().min(1),
109
+ description: z.string().nullable(),
110
+ typeTag: z.string().min(1),
111
+ classification: OrgUnitClassificationSchema.nullable(),
112
+ orderKey: z.string().min(1),
113
+ path: z.string().min(1),
114
+ syncMode: OrgUnitSyncModeSchema,
115
+ visibilityDefault: OrgUnitVisibilitySchema.nullable(),
116
+ metadata: z.record(z.string(), z.unknown()),
117
+ archivedAt: z.string().nullable(),
118
+ createdAt: z.string(),
119
+ updatedAt: z.string(),
120
+ });
121
+
122
+ /**
123
+ * Active users home directly at this unit who have no level-(depth+1) home
124
+ * assignment within its subtree. The settings UI surfaces this as a
125
+ * synthetic "[child level label] assignment missing" row above the
126
+ * children list at every level. `null` for leaf-level units (no children
127
+ * configured below them) where the concept is meaningless.
128
+ */
129
+ export const MissingAtNextLevelSchema = z.object({
130
+ count: z.number().int().min(0),
131
+ userIds: z.array(z.string().uuid()),
132
+ });
133
+
134
+ export const OrgUnitTreeNodeSchema = OrgUnitSchema.extend({
135
+ depth: z.number().int().min(1).max(ORG_UNIT_ABSOLUTE_MAX_DEPTH),
136
+ hasChildren: z.boolean(),
137
+ /**
138
+ * WHAT it counts is the `.describe()` below — the single field-level
139
+ * description, which reaches `openapi/backend.yaml` and the generated API
140
+ * types. This comment carries only WHY, so the two cannot drift into
141
+ * disagreeing.
142
+ *
143
+ * Why accounts: it is the right population for anything that needs an account
144
+ * to act on — the share dialog's fanout, the unit-scoped members table. It is
145
+ * backed by `home_assignments`, whose `user_id` is NOT NULL, so an accountless
146
+ * person structurally cannot appear in it. Deliberately NOT redefined and NOT
147
+ * deprecated; {@link placedPeopleCount} answers the headcount question, and the
148
+ * two legitimately disagree (ADR-CONTRACTS-150, ADR-BE-657).
149
+ */
150
+ memberCount: z
151
+ .number()
152
+ .int()
153
+ .min(0)
154
+ .describe(
155
+ "Accounts with a home assignment, rolled up over this unit's subtree. Legitimately 0 for a fully staffed department in an HRIS-imported org, where almost nobody has signed in — use placedPeopleCount for headcount.",
156
+ ),
157
+ /** Drives the "(N + M open)" count split in the people surfaces. */
158
+ openRoleCount: z
159
+ .number()
160
+ .int()
161
+ .min(0)
162
+ .describe(
163
+ "Active open roles (`open` + `hiring`) rolled up over this unit's subtree. Vacant seats only — an HRIS-imported seat is `lifecycle_owner='import:hris'` and is counted by neither this nor memberCount.",
164
+ ),
165
+ /**
166
+ * Predicate: `placement_role='canonical'`, `status <> 'ended'`, keyed by
167
+ * `positions.unit_id`. Placement and home are independent graphs
168
+ * (ADR-CTRL-159), so a person seated here may be homed elsewhere and vice
169
+ * versa. The `positions.status` non-filter stated in the `.describe()` below
170
+ * is deliberate and load-bearing — the backend pins it with a test whose name
171
+ * says so (ADR-BE-657).
172
+ */
173
+ placedPeopleCount: z
174
+ .number()
175
+ .int()
176
+ .min(0)
177
+ .describe(
178
+ "Distinct people holding a live canonical seat in this unit's subtree, whether or not they have an account. Deliberately does NOT filter positions.status: an occupied `closed` seat still draws a card on the org chart, so a filter here would disagree with it.",
179
+ ),
180
+ missingAtNextLevel: MissingAtNextLevelSchema.nullable(),
181
+ });
182
+
183
+ export const OrgUnitMembershipSchema = z.object({
184
+ id: z.string().uuid(),
185
+ orgId: z.string().uuid(),
186
+ unitId: z.string().uuid(),
187
+ userId: z.string().uuid(),
188
+ membershipRole: UnitMembershipRoleSchema,
189
+ status: OrgUnitMembershipStatusSchema,
190
+ source: OrgUnitMembershipSourceSchema,
191
+ sourceRef: z.string().nullable(),
192
+ createdAt: z.string(),
193
+ updatedAt: z.string(),
194
+ });
195
+
196
+ /**
197
+ * An open role — a persisted placeholder for the person who will sit in a unit.
198
+ * Carries unit membership (a seat) but never authority. `title` and
199
+ * `targetStartDate` are optional planning attributes. On `fill`, `filledByUserId`
200
+ * points at the seated user and a real `org_unit_membership` is created; the row
201
+ * is kept as staffing history. `targetStartDate` is an ISO date (YYYY-MM-DD).
202
+ */
203
+ export const OpenRoleSchema = z.object({
204
+ id: z.string().uuid(),
205
+ orgId: z.string().uuid(),
206
+ unitId: z.string().uuid(),
207
+ title: z.string().nullable(),
208
+ targetStartDate: z.string().nullable(),
209
+ status: OpenRoleStatusSchema,
210
+ filledByUserId: z.string().uuid().nullable(),
211
+ // The open role's own reporting edge (ADR-BE-291): defaults to its creator's
212
+ // seat, editable/clearable. Drives chart placement now that the unit-head
213
+ // anchor is retired (ADR-BE-292). Null → unmanaged (Unassigned bucket).
214
+ //
215
+ // The manager POSITION, not a user (ADR-CONTRACTS-131). The retired
216
+ // user-keyed flattening was a lie in two directions: an open-role manager
217
+ // already surfaced as null because it had no user to collapse to, and
218
+ // post-HRIS every open role reporting to an imported (accountless) manager
219
+ // would read null too.
220
+ reportsToPositionId: z.string().uuid().nullable(),
221
+ createdAt: z.string(),
222
+ updatedAt: z.string(),
223
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Ownership Transfer DTOs (PRD-00153)
3
+ *
4
+ * Request, status and acceptance-view shapes for the ownership-transfer UI flows,
5
+ * plus the acceptance-page responsibility checklist.
6
+ *
7
+ * Split out of `./types.ts`, which re-exports this module unchanged — import
8
+ * from `./types` (or the `./org` entry), not from this file directly.
9
+ */
10
+
11
+ // =============================================================================
12
+ // Ownership Transfer DTOs (PRD-00153)
13
+ // Rich types for the transfer UI flows, supplementing OwnershipTransferRequest
14
+ // and OwnershipTransferStatus in `./types`.
15
+ // =============================================================================
16
+
17
+ /** Request body for initiating ownership transfer (extends existing to include optional note). */
18
+ export interface TransferInitiateRequest {
19
+ readonly newOwnerEmail: string;
20
+ readonly note?: string;
21
+ }
22
+
23
+ /** Response for transfer status queries — extends existing OwnershipTransferStatus. */
24
+ export interface TransferStatusResponse {
25
+ readonly pending: boolean;
26
+ readonly newOwnerEmail?: string;
27
+ readonly note?: string;
28
+ readonly requestedAt?: string;
29
+ readonly expiresAt?: string;
30
+ readonly initiatorName?: string;
31
+ }
32
+
33
+ /** Read-only view for the transfer acceptance page. */
34
+ export interface TransferAcceptanceView {
35
+ readonly workspaceName: string;
36
+ readonly initiatorName: string;
37
+ readonly initiatorEmail: string;
38
+ readonly note?: string;
39
+ readonly expiresAt: string;
40
+ readonly token: string;
41
+ }
42
+
43
+ /** Responsibility checklist items shown on the acceptance page. */
44
+ export const TRANSFER_RESPONSIBILITIES = [
45
+ "You will become the sole owner of this workspace",
46
+ "You will be responsible for billing and compliance",
47
+ "The current owner will be downgraded to admin",
48
+ "This action cannot be undone without a new transfer",
49
+ ] as const;
50
+
51
+ export type TransferResponsibility = (typeof TRANSFER_RESPONSIBILITIES)[number];