@company-semantics/contracts 13.10.0 → 13.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "13.10.0",
3
+ "version": "13.12.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -2041,6 +2041,58 @@ export interface paths {
2041
2041
  patch?: never;
2042
2042
  trace?: never;
2043
2043
  };
2044
+ "/api/org-units/{unitId}/open-roles": {
2045
+ parameters: {
2046
+ query?: never;
2047
+ header?: never;
2048
+ path?: never;
2049
+ cookie?: never;
2050
+ };
2051
+ /** List open roles of an org unit */
2052
+ get: operations["listOrgUnitOpenRoles"];
2053
+ put?: never;
2054
+ /** Create an open role (placeholder seat) in an org unit */
2055
+ post: operations["createOrgUnitOpenRole"];
2056
+ delete?: never;
2057
+ options?: never;
2058
+ head?: never;
2059
+ patch?: never;
2060
+ trace?: never;
2061
+ };
2062
+ "/api/org-units/{unitId}/open-roles/{roleId}": {
2063
+ parameters: {
2064
+ query?: never;
2065
+ header?: never;
2066
+ path?: never;
2067
+ cookie?: never;
2068
+ };
2069
+ get?: never;
2070
+ put?: never;
2071
+ post?: never;
2072
+ delete?: never;
2073
+ options?: never;
2074
+ head?: never;
2075
+ /** Advance (hiring) or close an open role */
2076
+ patch: operations["updateOrgUnitOpenRoleStatus"];
2077
+ trace?: never;
2078
+ };
2079
+ "/api/org-units/{unitId}/open-roles/{roleId}/fill": {
2080
+ parameters: {
2081
+ query?: never;
2082
+ header?: never;
2083
+ path?: never;
2084
+ cookie?: never;
2085
+ };
2086
+ get?: never;
2087
+ put?: never;
2088
+ /** Fill an open role by seating an existing member or inviting a person */
2089
+ post: operations["fillOrgUnitOpenRole"];
2090
+ delete?: never;
2091
+ options?: never;
2092
+ head?: never;
2093
+ patch?: never;
2094
+ trace?: never;
2095
+ };
2044
2096
  "/api/users/org-chart": {
2045
2097
  parameters: {
2046
2098
  query?: never;
@@ -4221,6 +4273,7 @@ export interface components {
4221
4273
  depth: number;
4222
4274
  hasChildren: boolean;
4223
4275
  memberCount: number;
4276
+ openRoleCount: number;
4224
4277
  missingAtNextLevel: {
4225
4278
  count: number;
4226
4279
  userIds: string[];
@@ -4368,6 +4421,7 @@ export interface components {
4368
4421
  depth: number;
4369
4422
  hasChildren: boolean;
4370
4423
  memberCount: number;
4424
+ openRoleCount: number;
4371
4425
  missingAtNextLevel: {
4372
4426
  count: number;
4373
4427
  userIds: string[];
@@ -4570,6 +4624,42 @@ export interface components {
4570
4624
  updatedAt: string;
4571
4625
  };
4572
4626
  };
4627
+ OpenRoleListResponse: {
4628
+ /** Format: uuid */
4629
+ unitId: string;
4630
+ openRoles: {
4631
+ /** Format: uuid */
4632
+ id: string;
4633
+ /** Format: uuid */
4634
+ orgId: string;
4635
+ /** Format: uuid */
4636
+ unitId: string;
4637
+ title: string | null;
4638
+ targetStartDate: string | null;
4639
+ /** @enum {string} */
4640
+ status: "open" | "hiring" | "filled" | "closed";
4641
+ filledByUserId: string | null;
4642
+ createdAt: string;
4643
+ updatedAt: string;
4644
+ }[];
4645
+ };
4646
+ OpenRoleResponse: {
4647
+ openRole: {
4648
+ /** Format: uuid */
4649
+ id: string;
4650
+ /** Format: uuid */
4651
+ orgId: string;
4652
+ /** Format: uuid */
4653
+ unitId: string;
4654
+ title: string | null;
4655
+ targetStartDate: string | null;
4656
+ /** @enum {string} */
4657
+ status: "open" | "hiring" | "filled" | "closed";
4658
+ filledByUserId: string | null;
4659
+ createdAt: string;
4660
+ updatedAt: string;
4661
+ };
4662
+ };
4573
4663
  PeopleOrgChartResponse: {
4574
4664
  nodes: {
4575
4665
  /** Format: uuid */
@@ -4587,6 +4677,13 @@ export interface components {
4587
4677
  /** @enum {string} */
4588
4678
  relationshipType: "solid" | "dotted";
4589
4679
  }[];
4680
+ openRoles: {
4681
+ /** Format: uuid */
4682
+ id: string;
4683
+ /** Format: uuid */
4684
+ unitId: string;
4685
+ title: string | null;
4686
+ }[];
4590
4687
  };
4591
4688
  /** @description Polling snapshot of a generic ingestion operation. */
4592
4689
  IngestionOperationPollResponse: {
@@ -8463,6 +8560,126 @@ export interface operations {
8463
8560
  };
8464
8561
  };
8465
8562
  };
8563
+ listOrgUnitOpenRoles: {
8564
+ parameters: {
8565
+ query?: never;
8566
+ header?: never;
8567
+ path: {
8568
+ unitId: string;
8569
+ };
8570
+ cookie?: never;
8571
+ };
8572
+ requestBody?: never;
8573
+ responses: {
8574
+ /** @description Open roles of the unit (all lifecycle states) */
8575
+ 200: {
8576
+ headers: {
8577
+ [name: string]: unknown;
8578
+ };
8579
+ content: {
8580
+ "application/json": components["schemas"]["OpenRoleListResponse"];
8581
+ };
8582
+ };
8583
+ };
8584
+ };
8585
+ createOrgUnitOpenRole: {
8586
+ parameters: {
8587
+ query?: never;
8588
+ header?: never;
8589
+ path: {
8590
+ unitId: string;
8591
+ };
8592
+ cookie?: never;
8593
+ };
8594
+ requestBody: {
8595
+ content: {
8596
+ "application/json": {
8597
+ title?: string | null;
8598
+ targetStartDate?: string | null;
8599
+ };
8600
+ };
8601
+ };
8602
+ responses: {
8603
+ /** @description Created open role */
8604
+ 201: {
8605
+ headers: {
8606
+ [name: string]: unknown;
8607
+ };
8608
+ content: {
8609
+ "application/json": components["schemas"]["OpenRoleResponse"];
8610
+ };
8611
+ };
8612
+ };
8613
+ };
8614
+ updateOrgUnitOpenRoleStatus: {
8615
+ parameters: {
8616
+ query?: never;
8617
+ header?: never;
8618
+ path: {
8619
+ unitId: string;
8620
+ roleId: string;
8621
+ };
8622
+ cookie?: never;
8623
+ };
8624
+ requestBody: {
8625
+ content: {
8626
+ "application/json": {
8627
+ /** @enum {string} */
8628
+ status: "hiring" | "closed";
8629
+ };
8630
+ };
8631
+ };
8632
+ responses: {
8633
+ /** @description Updated open role */
8634
+ 200: {
8635
+ headers: {
8636
+ [name: string]: unknown;
8637
+ };
8638
+ content: {
8639
+ "application/json": components["schemas"]["OpenRoleResponse"];
8640
+ };
8641
+ };
8642
+ };
8643
+ };
8644
+ fillOrgUnitOpenRole: {
8645
+ parameters: {
8646
+ query?: never;
8647
+ header?: never;
8648
+ path: {
8649
+ unitId: string;
8650
+ roleId: string;
8651
+ };
8652
+ cookie?: never;
8653
+ };
8654
+ requestBody: {
8655
+ content: {
8656
+ "application/json": {
8657
+ /** @constant */
8658
+ mode: "existing";
8659
+ /** Format: uuid */
8660
+ userId: string;
8661
+ } | {
8662
+ /** @constant */
8663
+ mode: "invite";
8664
+ /** Format: email */
8665
+ email: string;
8666
+ /** @enum {string} */
8667
+ role: "admin" | "member";
8668
+ };
8669
+ };
8670
+ };
8671
+ responses: {
8672
+ /** @description Filled open role */
8673
+ 200: {
8674
+ headers: {
8675
+ [name: string]: unknown;
8676
+ };
8677
+ content: {
8678
+ "application/json": components["schemas"]["OpenRoleResponse"];
8679
+ };
8680
+ };
8681
+ };
8682
+ };
8466
8683
  getPeopleOrgChart: {
8467
8684
  parameters: {
8468
8685
  query?: never;
@@ -86,6 +86,9 @@ export const openApiRoutes = {
86
86
  '/api/org-units/{unitId}/memberships/{userId}': ['DELETE'],
87
87
  '/api/org-units/{unitId}/memberships/{userId}/role': ['PUT'],
88
88
  '/api/org-units/{unitId}/my-authority': ['GET'],
89
+ '/api/org-units/{unitId}/open-roles': ['GET', 'POST'],
90
+ '/api/org-units/{unitId}/open-roles/{roleId}': ['PATCH'],
91
+ '/api/org-units/{unitId}/open-roles/{roleId}/fill': ['POST'],
89
92
  '/api/org-units/{unitId}/owners': ['GET'],
90
93
  '/api/org-units/{unitId}/permissions': ['GET'],
91
94
  '/api/org-units/{unitId}/relationships': ['GET', 'POST'],
@@ -66,11 +66,13 @@ export {
66
66
  ReportingRelationshipTypeSchema,
67
67
  PeopleOrgChartNodeSchema,
68
68
  PeopleOrgChartEdgeSchema,
69
+ PeopleOrgChartOpenRoleSchema,
69
70
  PeopleOrgChartResponseSchema,
70
71
  } from "./people-org-chart";
71
72
  export type {
72
73
  ReportingRelationshipType,
73
74
  PeopleOrgChartNode,
74
75
  PeopleOrgChartEdge,
76
+ PeopleOrgChartOpenRole,
75
77
  PeopleOrgChartResponse,
76
78
  } from "./people-org-chart";
@@ -48,6 +48,24 @@ export const PeopleOrgChartEdgeSchema = z.object({
48
48
 
49
49
  export type PeopleOrgChartEdge = z.infer<typeof PeopleOrgChartEdgeSchema>;
50
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Open role — a persisted placeholder seat rendered as a dashed "Open role"
53
+ // card in its unit's sibling column. Only active roles (open + hiring) are
54
+ // included; filled/closed roles never appear here. `unitId` seats the card in
55
+ // the right column and resolves its attach-person, mirroring how empty-unit
56
+ // placeholders and pending-invite ghosts are positioned.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export const PeopleOrgChartOpenRoleSchema = z.object({
60
+ id: z.string().uuid(),
61
+ unitId: z.string().uuid(),
62
+ title: z.string().nullable(),
63
+ });
64
+
65
+ export type PeopleOrgChartOpenRole = z.infer<
66
+ typeof PeopleOrgChartOpenRoleSchema
67
+ >;
68
+
51
69
  // ---------------------------------------------------------------------------
52
70
  // GET /api/users/org-chart
53
71
  // ---------------------------------------------------------------------------
@@ -55,6 +73,7 @@ export type PeopleOrgChartEdge = z.infer<typeof PeopleOrgChartEdgeSchema>;
55
73
  export const PeopleOrgChartResponseSchema = z.object({
56
74
  nodes: z.array(PeopleOrgChartNodeSchema),
57
75
  edges: z.array(PeopleOrgChartEdgeSchema),
76
+ openRoles: z.array(PeopleOrgChartOpenRoleSchema),
58
77
  });
59
78
 
60
79
  export type PeopleOrgChartResponse = z.infer<
package/src/index.ts CHANGED
@@ -172,12 +172,14 @@ export {
172
172
  ReportingRelationshipTypeSchema,
173
173
  PeopleOrgChartNodeSchema,
174
174
  PeopleOrgChartEdgeSchema,
175
+ PeopleOrgChartOpenRoleSchema,
175
176
  PeopleOrgChartResponseSchema,
176
177
  } from "./identity/index";
177
178
  export type {
178
179
  ReportingRelationshipType,
179
180
  PeopleOrgChartNode,
180
181
  PeopleOrgChartEdge,
182
+ PeopleOrgChartOpenRole,
181
183
  PeopleOrgChartResponse,
182
184
  } from "./identity/index";
183
185
 
@@ -14,6 +14,10 @@ import {
14
14
  OrgUnitMembershipSourceSchema,
15
15
  OrgUnitPermissionsEntrySchema,
16
16
  ListOrgUnitPermissionsResponseSchema,
17
+ OpenRoleSchema,
18
+ CreateOpenRoleRequestSchema,
19
+ UpdateOpenRoleStatusRequestSchema,
20
+ FillOpenRoleRequestSchema,
17
21
  } from "../schemas.js";
18
22
 
19
23
  const UUID_A = "11111111-1111-4111-8111-111111111111";
@@ -92,12 +96,113 @@ describe("OrgUnitTreeNodeSchema", () => {
92
96
  depth: 3,
93
97
  hasChildren: true,
94
98
  memberCount: 5,
99
+ openRoleCount: 1,
95
100
  missingAtNextLevel: null,
96
101
  };
97
102
  expect(() => OrgUnitTreeNodeSchema.parse(base)).not.toThrow();
98
103
  expect(() => OrgUnitTreeNodeSchema.parse({ ...base, depth: 0 })).toThrow();
99
104
  expect(() => OrgUnitTreeNodeSchema.parse({ ...base, depth: 6 })).toThrow();
100
105
  });
106
+
107
+ it("requires a non-negative openRoleCount", () => {
108
+ const base = {
109
+ ...makeUnit(),
110
+ depth: 3,
111
+ hasChildren: true,
112
+ memberCount: 5,
113
+ openRoleCount: 0,
114
+ missingAtNextLevel: null,
115
+ };
116
+ expect(() => OrgUnitTreeNodeSchema.parse(base)).not.toThrow();
117
+ expect(() =>
118
+ OrgUnitTreeNodeSchema.parse({ ...base, openRoleCount: -1 }),
119
+ ).toThrow();
120
+ });
121
+ });
122
+
123
+ describe("OpenRoleSchema", () => {
124
+ const makeOpenRole = (overrides: Record<string, unknown> = {}) => ({
125
+ id: UUID_A,
126
+ orgId: UUID_B,
127
+ unitId: UUID_C,
128
+ title: "Senior Engineer",
129
+ targetStartDate: "2026-09-01",
130
+ status: "open",
131
+ filledByUserId: null,
132
+ createdAt: "2026-04-17T00:00:00Z",
133
+ updatedAt: "2026-04-17T00:00:00Z",
134
+ ...overrides,
135
+ });
136
+
137
+ it("accepts a valid open role and the four lifecycle states", () => {
138
+ expect(() => OpenRoleSchema.parse(makeOpenRole())).not.toThrow();
139
+ for (const status of ["open", "hiring", "filled", "closed"]) {
140
+ expect(() =>
141
+ OpenRoleSchema.parse(makeOpenRole({ status })),
142
+ ).not.toThrow();
143
+ }
144
+ });
145
+
146
+ it("allows null title and targetStartDate, rejects unknown status", () => {
147
+ expect(() =>
148
+ OpenRoleSchema.parse(
149
+ makeOpenRole({ title: null, targetStartDate: null }),
150
+ ),
151
+ ).not.toThrow();
152
+ expect(() =>
153
+ OpenRoleSchema.parse(makeOpenRole({ status: "vacant" })),
154
+ ).toThrow();
155
+ });
156
+ });
157
+
158
+ describe("Open role request schemas", () => {
159
+ it("CreateOpenRoleRequest accepts empty body and ISO date, rejects bad date", () => {
160
+ expect(() => CreateOpenRoleRequestSchema.parse({})).not.toThrow();
161
+ expect(() =>
162
+ CreateOpenRoleRequestSchema.parse({
163
+ title: "PM",
164
+ targetStartDate: "2026-09-01",
165
+ }),
166
+ ).not.toThrow();
167
+ expect(() =>
168
+ CreateOpenRoleRequestSchema.parse({ targetStartDate: "Sept 1" }),
169
+ ).toThrow();
170
+ });
171
+
172
+ it("UpdateOpenRoleStatusRequest only accepts hiring|closed", () => {
173
+ expect(() =>
174
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "hiring" }),
175
+ ).not.toThrow();
176
+ expect(() =>
177
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "closed" }),
178
+ ).not.toThrow();
179
+ expect(() =>
180
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "filled" }),
181
+ ).toThrow();
182
+ expect(() =>
183
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "open" }),
184
+ ).toThrow();
185
+ });
186
+
187
+ it("FillOpenRoleRequest discriminates on mode", () => {
188
+ expect(() =>
189
+ FillOpenRoleRequestSchema.parse({ mode: "existing", userId: UUID_A }),
190
+ ).not.toThrow();
191
+ expect(() =>
192
+ FillOpenRoleRequestSchema.parse({
193
+ mode: "invite",
194
+ email: "a@b.com",
195
+ role: "member",
196
+ }),
197
+ ).not.toThrow();
198
+ // wrong branch fields
199
+ expect(() =>
200
+ FillOpenRoleRequestSchema.parse({ mode: "existing", email: "a@b.com" }),
201
+ ).toThrow();
202
+ expect(() =>
203
+ FillOpenRoleRequestSchema.parse({ mode: "invite", userId: UUID_A }),
204
+ ).toThrow();
205
+ });
101
206
  });
102
207
 
103
208
  describe("OrgUnitMembershipSchema", () => {
package/src/org/index.ts CHANGED
@@ -219,6 +219,7 @@ export type {
219
219
  OrgUnitRelationshipRole,
220
220
  OrgUnitMembershipStatus,
221
221
  OrgUnitMembershipSource,
222
+ OpenRoleStatus,
222
223
  OrgUnitErrorCode,
223
224
  OrgTreeResponse,
224
225
  } from "./org-units";
@@ -236,9 +237,17 @@ export {
236
237
  OrgUnitSchema,
237
238
  OrgUnitTreeNodeSchema,
238
239
  OrgUnitMembershipSchema,
240
+ OpenRoleStatusSchema,
241
+ OpenRoleSchema,
242
+ OpenRoleResponseSchema,
243
+ OpenRoleListResponseSchema,
244
+ CreateOpenRoleRequestSchema,
245
+ UpdateOpenRoleStatusRequestSchema,
246
+ FillOpenRoleRequestSchema,
239
247
  OrgUnitRelationshipSchema,
240
248
  OrgLevelConfigSchema,
241
249
  OrgLevelIconSchema,
250
+ OrgOwnerIconSchema,
242
251
  OrgUnitResponseSchema,
243
252
  OrgUnitTreeResponseSchema,
244
253
  MissingAtNextLevelSchema,
@@ -259,9 +268,16 @@ export type {
259
268
  OrgUnit,
260
269
  OrgUnitTreeNode,
261
270
  OrgUnitMembership,
271
+ OpenRole,
272
+ OpenRoleResponse,
273
+ OpenRoleListResponse,
274
+ CreateOpenRoleRequest,
275
+ UpdateOpenRoleStatusRequest,
276
+ FillOpenRoleRequest,
262
277
  OrgUnitRelationship,
263
278
  OrgLevelConfig,
264
279
  OrgLevelIcon,
280
+ OrgOwnerIcon,
265
281
  OrgUnitResponse,
266
282
  OrgUnitTreeResponse,
267
283
  MissingAtNextLevel,
@@ -56,6 +56,19 @@ export type OrgUnitMembershipSource =
56
56
  | "scim"
57
57
  | "hris";
58
58
 
59
+ /**
60
+ * Hiring lifecycle of an open role — a persisted placeholder for the person
61
+ * who will sit in an org unit (PRD open-roles). A unit is seeded with one
62
+ * `open` role at creation so it never renders as a unit with nobody.
63
+ *
64
+ * Transitions: `open → hiring → filled` and `open|hiring → closed`. `filled`
65
+ * and `closed` are terminal. `filled` means a real `org_unit_membership` was
66
+ * created from this role (the row is kept as staffing history); `closed` means
67
+ * the seat was cancelled. Only `open` and `hiring` are "active" — they are the
68
+ * states that render an Open role card and count toward `openRoleCount`.
69
+ */
70
+ export type OpenRoleStatus = "open" | "hiring" | "filled" | "closed";
71
+
59
72
  /**
60
73
  * Canonical route path constants for the `/api/org-units` surface.
61
74
  *
@@ -87,6 +100,11 @@ export const ORG_UNITS_ROUTES = {
87
100
  membershipRole: (unitId: string, userId: string) =>
88
101
  `/api/org-units/${unitId}/memberships/${userId}/role`,
89
102
  permissions: (unitId: string) => `/api/org-units/${unitId}/permissions`,
103
+ openRoles: (unitId: string) => `/api/org-units/${unitId}/open-roles`,
104
+ openRoleById: (unitId: string, roleId: string) =>
105
+ `/api/org-units/${unitId}/open-roles/${roleId}`,
106
+ fillOpenRole: (unitId: string, roleId: string) =>
107
+ `/api/org-units/${unitId}/open-roles/${roleId}/fill`,
90
108
  } as const;
91
109
 
92
110
  /**
@@ -158,6 +158,19 @@ export type WorkspaceAccessResponse = z.infer<
158
158
  // PATCH /api/workspace/name (returns WorkspaceOverview)
159
159
  // ---------------------------------------------------------------------------
160
160
 
161
+ /**
162
+ * Icon options for the `owner` org-chart role (ADR-CONT-070). Selected
163
+ * alongside the owner title in the Organization structure editor; `crown` is
164
+ * the default. Presentation only — never an authorization input.
165
+ */
166
+ export const OrgOwnerIconSchema = z.enum([
167
+ "crown",
168
+ "crown-cross",
169
+ "person",
170
+ "person-simple-circle",
171
+ "medal-military",
172
+ ]);
173
+
161
174
  export const WorkspaceOverviewSchema = z.object({
162
175
  id: z.string(),
163
176
  name: z.string(),
@@ -173,11 +186,19 @@ export const WorkspaceOverviewSchema = z.object({
173
186
  * section heading renders. Presentation only — never an authorization input.
174
187
  */
175
188
  ownerTitle: z.string(),
189
+ /**
190
+ * Org-configured icon for the `owner` org-chart role (ADR-CONT-070).
191
+ * Server-resolved: `orgs.owner_icon` when set, otherwise the default
192
+ * "crown". Always present. Presentation only — never an authorization input.
193
+ */
194
+ ownerIcon: OrgOwnerIconSchema,
176
195
  createdAt: z.string(),
177
196
  memberCount: z.number(),
178
197
  claimable: z.boolean(),
179
198
  });
180
199
 
200
+ export type OrgOwnerIcon = z.infer<typeof OrgOwnerIconSchema>;
201
+
181
202
  export type WorkspaceOverview = z.infer<typeof WorkspaceOverviewSchema>;
182
203
 
183
204
  // ---------------------------------------------------------------------------
@@ -855,6 +876,18 @@ export const OrgUnitMembershipSourceSchema = z.enum([
855
876
  "hris",
856
877
  ]);
857
878
 
879
+ /**
880
+ * Hiring lifecycle of an open role. See `OpenRoleStatus` in `./org-units` for
881
+ * the transition rules. Only `open` and `hiring` are "active" (render a card
882
+ * and count toward `openRoleCount`); `filled`/`closed` are terminal.
883
+ */
884
+ export const OpenRoleStatusSchema = z.enum([
885
+ "open",
886
+ "hiring",
887
+ "filled",
888
+ "closed",
889
+ ]);
890
+
858
891
  export const OrgUnitErrorCodeSchema = z.enum([
859
892
  "CYCLE_BLOCKED",
860
893
  "DEPTH_EXCEEDED",
@@ -903,6 +936,12 @@ export const OrgUnitTreeNodeSchema = OrgUnitSchema.extend({
903
936
  depth: z.number().int().min(1).max(5),
904
937
  hasChildren: z.boolean(),
905
938
  memberCount: z.number().int().min(0),
939
+ /**
940
+ * Active open roles (`open` + `hiring`) rolled up over this unit's subtree,
941
+ * the same way `memberCount` rolls up real members. Drives the "(N + M open)"
942
+ * count split in the people surfaces. `memberCount` stays real-members-only.
943
+ */
944
+ openRoleCount: z.number().int().min(0),
906
945
  missingAtNextLevel: MissingAtNextLevelSchema.nullable(),
907
946
  });
908
947
 
@@ -919,6 +958,25 @@ export const OrgUnitMembershipSchema = z.object({
919
958
  updatedAt: z.string(),
920
959
  });
921
960
 
961
+ /**
962
+ * An open role — a persisted placeholder for the person who will sit in a unit.
963
+ * Carries unit membership (a seat) but never authority. `title` and
964
+ * `targetStartDate` are optional planning attributes. On `fill`, `filledByUserId`
965
+ * points at the seated user and a real `org_unit_membership` is created; the row
966
+ * is kept as staffing history. `targetStartDate` is an ISO date (YYYY-MM-DD).
967
+ */
968
+ export const OpenRoleSchema = z.object({
969
+ id: z.string().uuid(),
970
+ orgId: z.string().uuid(),
971
+ unitId: z.string().uuid(),
972
+ title: z.string().nullable(),
973
+ targetStartDate: z.string().nullable(),
974
+ status: OpenRoleStatusSchema,
975
+ filledByUserId: z.string().uuid().nullable(),
976
+ createdAt: z.string(),
977
+ updatedAt: z.string(),
978
+ });
979
+
922
980
  // ---------------------------------------------------------------------------
923
981
  // Authority & Delegation vocabulary
924
982
  //
@@ -1177,6 +1235,49 @@ export const OrgUnitMembershipListResponseSchema = z.object({
1177
1235
  memberships: z.array(OrgUnitMembershipSchema),
1178
1236
  });
1179
1237
 
1238
+ // ---------------------------------------------------------------------------
1239
+ // Open roles (PRD open-roles)
1240
+ // Request schemas are colocated here (a narrow deviation from ADR-CONT-044, as
1241
+ // with UpdateOrgUnitRequest) so the app's add-role form and the backend
1242
+ // validate against one published shape.
1243
+ // ---------------------------------------------------------------------------
1244
+
1245
+ export const OpenRoleResponseSchema = z.object({ openRole: OpenRoleSchema });
1246
+
1247
+ export const OpenRoleListResponseSchema = z.object({
1248
+ unitId: z.string().uuid(),
1249
+ openRoles: z.array(OpenRoleSchema),
1250
+ });
1251
+
1252
+ /** ISO calendar date (YYYY-MM-DD) — version-agnostic vs `z.string().date()`. */
1253
+ const IsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
1254
+ message: "must be an ISO date (YYYY-MM-DD)",
1255
+ });
1256
+
1257
+ export const CreateOpenRoleRequestSchema = z.object({
1258
+ title: z.string().min(1).max(255).nullable().optional(),
1259
+ targetStartDate: IsoDateSchema.nullable().optional(),
1260
+ });
1261
+
1262
+ /** Only the non-terminal client transitions are accepted; `fill` is its own
1263
+ * endpoint and `open` is the seeded initial state, never set by the client. */
1264
+ export const UpdateOpenRoleStatusRequestSchema = z.object({
1265
+ status: z.enum(["hiring", "closed"]),
1266
+ });
1267
+
1268
+ /**
1269
+ * Fill an open role by seating an existing org member (`userId`) or inviting a
1270
+ * new person by email. Exactly one branch — a discriminated union keyed on `mode`.
1271
+ */
1272
+ export const FillOpenRoleRequestSchema = z.discriminatedUnion("mode", [
1273
+ z.object({ mode: z.literal("existing"), userId: z.string().uuid() }),
1274
+ z.object({
1275
+ mode: z.literal("invite"),
1276
+ email: z.string().email(),
1277
+ role: z.enum(["admin", "member"]),
1278
+ }),
1279
+ ]);
1280
+
1180
1281
  /**
1181
1282
  * Effective manager of an org unit — a user whose membership (direct or
1182
1283
  * inherited from an ancestor) grants the `orgUnit.{id}.manage` capability.
@@ -1249,6 +1350,14 @@ export type OrgUnitMembershipResponse = z.infer<
1249
1350
  export type OrgUnitMembershipListResponse = z.infer<
1250
1351
  typeof OrgUnitMembershipListResponseSchema
1251
1352
  >;
1353
+ export type OpenRole = z.infer<typeof OpenRoleSchema>;
1354
+ export type OpenRoleResponse = z.infer<typeof OpenRoleResponseSchema>;
1355
+ export type OpenRoleListResponse = z.infer<typeof OpenRoleListResponseSchema>;
1356
+ export type CreateOpenRoleRequest = z.infer<typeof CreateOpenRoleRequestSchema>;
1357
+ export type UpdateOpenRoleStatusRequest = z.infer<
1358
+ typeof UpdateOpenRoleStatusRequestSchema
1359
+ >;
1360
+ export type FillOpenRoleRequest = z.infer<typeof FillOpenRoleRequestSchema>;
1252
1361
  export type OrgUnitPermissionsEntry = z.infer<
1253
1362
  typeof OrgUnitPermissionsEntrySchema
1254
1363
  >;
package/src/org/types.ts CHANGED
@@ -78,6 +78,17 @@ export interface WorkspaceOverview {
78
78
  * Mirror of `WorkspaceOverviewSchema.ownerTitle` — keep in lockstep.
79
79
  */
80
80
  ownerTitle: string;
81
+ /**
82
+ * Org-configured icon for the `owner` org-chart role (ADR-CONT-070).
83
+ * Server-resolved (`orgs.owner_icon` else "crown"). Mirror of
84
+ * `WorkspaceOverviewSchema.ownerIcon` / `OrgOwnerIconSchema` — keep in lockstep.
85
+ */
86
+ ownerIcon:
87
+ | "crown"
88
+ | "crown-cross"
89
+ | "person"
90
+ | "person-simple-circle"
91
+ | "medal-military";
81
92
  createdAt: string;
82
93
  memberCount: number;
83
94
  claimable: boolean;