@company-semantics/contracts 13.9.0 → 13.11.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 +1 -1
- package/src/api/generated.ts +214 -0
- package/src/generated/openapi-routes.ts +3 -0
- package/src/identity/index.ts +2 -0
- package/src/identity/people-org-chart.ts +19 -0
- package/src/index.ts +2 -0
- package/src/org/__tests__/org-units.test.ts +105 -0
- package/src/org/__tests__/view-scopes.test.ts +2 -0
- package/src/org/index.ts +14 -0
- package/src/org/org-units.ts +18 -0
- package/src/org/schemas.ts +88 -0
- package/src/org/view-scopes.ts +5 -0
package/package.json
CHANGED
package/src/api/generated.ts
CHANGED
|
@@ -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;
|
|
@@ -3636,6 +3688,8 @@ export interface components {
|
|
|
3636
3688
|
};
|
|
3637
3689
|
/** @enum {string} */
|
|
3638
3690
|
status: "pending" | "accepted" | "expired" | "revoked";
|
|
3691
|
+
/** Format: uuid */
|
|
3692
|
+
homeUnitId?: string;
|
|
3639
3693
|
createdAt: string;
|
|
3640
3694
|
expiresAt: string;
|
|
3641
3695
|
acceptedAt?: string;
|
|
@@ -3646,6 +3700,8 @@ export interface components {
|
|
|
3646
3700
|
email: string;
|
|
3647
3701
|
/** @enum {string} */
|
|
3648
3702
|
role: "admin" | "member";
|
|
3703
|
+
/** Format: uuid */
|
|
3704
|
+
homeUnitId?: string;
|
|
3649
3705
|
};
|
|
3650
3706
|
InviteListResponse: {
|
|
3651
3707
|
id: string;
|
|
@@ -3659,6 +3715,8 @@ export interface components {
|
|
|
3659
3715
|
};
|
|
3660
3716
|
/** @enum {string} */
|
|
3661
3717
|
status: "pending" | "accepted" | "expired" | "revoked";
|
|
3718
|
+
/** Format: uuid */
|
|
3719
|
+
homeUnitId?: string;
|
|
3662
3720
|
createdAt: string;
|
|
3663
3721
|
expiresAt: string;
|
|
3664
3722
|
acceptedAt?: string;
|
|
@@ -4564,6 +4622,42 @@ export interface components {
|
|
|
4564
4622
|
updatedAt: string;
|
|
4565
4623
|
};
|
|
4566
4624
|
};
|
|
4625
|
+
OpenRoleListResponse: {
|
|
4626
|
+
/** Format: uuid */
|
|
4627
|
+
unitId: string;
|
|
4628
|
+
openRoles: {
|
|
4629
|
+
/** Format: uuid */
|
|
4630
|
+
id: string;
|
|
4631
|
+
/** Format: uuid */
|
|
4632
|
+
orgId: string;
|
|
4633
|
+
/** Format: uuid */
|
|
4634
|
+
unitId: string;
|
|
4635
|
+
title: string | null;
|
|
4636
|
+
targetStartDate: string | null;
|
|
4637
|
+
/** @enum {string} */
|
|
4638
|
+
status: "open" | "hiring" | "filled" | "closed";
|
|
4639
|
+
filledByUserId: string | null;
|
|
4640
|
+
createdAt: string;
|
|
4641
|
+
updatedAt: string;
|
|
4642
|
+
}[];
|
|
4643
|
+
};
|
|
4644
|
+
OpenRoleResponse: {
|
|
4645
|
+
openRole: {
|
|
4646
|
+
/** Format: uuid */
|
|
4647
|
+
id: string;
|
|
4648
|
+
/** Format: uuid */
|
|
4649
|
+
orgId: string;
|
|
4650
|
+
/** Format: uuid */
|
|
4651
|
+
unitId: string;
|
|
4652
|
+
title: string | null;
|
|
4653
|
+
targetStartDate: string | null;
|
|
4654
|
+
/** @enum {string} */
|
|
4655
|
+
status: "open" | "hiring" | "filled" | "closed";
|
|
4656
|
+
filledByUserId: string | null;
|
|
4657
|
+
createdAt: string;
|
|
4658
|
+
updatedAt: string;
|
|
4659
|
+
};
|
|
4660
|
+
};
|
|
4567
4661
|
PeopleOrgChartResponse: {
|
|
4568
4662
|
nodes: {
|
|
4569
4663
|
/** Format: uuid */
|
|
@@ -8457,6 +8551,126 @@ export interface operations {
|
|
|
8457
8551
|
};
|
|
8458
8552
|
};
|
|
8459
8553
|
};
|
|
8554
|
+
listOrgUnitOpenRoles: {
|
|
8555
|
+
parameters: {
|
|
8556
|
+
query?: never;
|
|
8557
|
+
header?: never;
|
|
8558
|
+
path: {
|
|
8559
|
+
unitId: string;
|
|
8560
|
+
};
|
|
8561
|
+
cookie?: never;
|
|
8562
|
+
};
|
|
8563
|
+
requestBody?: never;
|
|
8564
|
+
responses: {
|
|
8565
|
+
/** @description Open roles of the unit (all lifecycle states) */
|
|
8566
|
+
200: {
|
|
8567
|
+
headers: {
|
|
8568
|
+
[name: string]: unknown;
|
|
8569
|
+
};
|
|
8570
|
+
content: {
|
|
8571
|
+
"application/json": components["schemas"]["OpenRoleListResponse"];
|
|
8572
|
+
};
|
|
8573
|
+
};
|
|
8574
|
+
};
|
|
8575
|
+
};
|
|
8576
|
+
createOrgUnitOpenRole: {
|
|
8577
|
+
parameters: {
|
|
8578
|
+
query?: never;
|
|
8579
|
+
header?: never;
|
|
8580
|
+
path: {
|
|
8581
|
+
unitId: string;
|
|
8582
|
+
};
|
|
8583
|
+
cookie?: never;
|
|
8584
|
+
};
|
|
8585
|
+
requestBody: {
|
|
8586
|
+
content: {
|
|
8587
|
+
"application/json": {
|
|
8588
|
+
title?: string | null;
|
|
8589
|
+
targetStartDate?: string | null;
|
|
8590
|
+
};
|
|
8591
|
+
};
|
|
8592
|
+
};
|
|
8593
|
+
responses: {
|
|
8594
|
+
/** @description Created open role */
|
|
8595
|
+
201: {
|
|
8596
|
+
headers: {
|
|
8597
|
+
[name: string]: unknown;
|
|
8598
|
+
};
|
|
8599
|
+
content: {
|
|
8600
|
+
"application/json": components["schemas"]["OpenRoleResponse"];
|
|
8601
|
+
};
|
|
8602
|
+
};
|
|
8603
|
+
};
|
|
8604
|
+
};
|
|
8605
|
+
updateOrgUnitOpenRoleStatus: {
|
|
8606
|
+
parameters: {
|
|
8607
|
+
query?: never;
|
|
8608
|
+
header?: never;
|
|
8609
|
+
path: {
|
|
8610
|
+
unitId: string;
|
|
8611
|
+
roleId: string;
|
|
8612
|
+
};
|
|
8613
|
+
cookie?: never;
|
|
8614
|
+
};
|
|
8615
|
+
requestBody: {
|
|
8616
|
+
content: {
|
|
8617
|
+
"application/json": {
|
|
8618
|
+
/** @enum {string} */
|
|
8619
|
+
status: "hiring" | "closed";
|
|
8620
|
+
};
|
|
8621
|
+
};
|
|
8622
|
+
};
|
|
8623
|
+
responses: {
|
|
8624
|
+
/** @description Updated open role */
|
|
8625
|
+
200: {
|
|
8626
|
+
headers: {
|
|
8627
|
+
[name: string]: unknown;
|
|
8628
|
+
};
|
|
8629
|
+
content: {
|
|
8630
|
+
"application/json": components["schemas"]["OpenRoleResponse"];
|
|
8631
|
+
};
|
|
8632
|
+
};
|
|
8633
|
+
};
|
|
8634
|
+
};
|
|
8635
|
+
fillOrgUnitOpenRole: {
|
|
8636
|
+
parameters: {
|
|
8637
|
+
query?: never;
|
|
8638
|
+
header?: never;
|
|
8639
|
+
path: {
|
|
8640
|
+
unitId: string;
|
|
8641
|
+
roleId: string;
|
|
8642
|
+
};
|
|
8643
|
+
cookie?: never;
|
|
8644
|
+
};
|
|
8645
|
+
requestBody: {
|
|
8646
|
+
content: {
|
|
8647
|
+
"application/json": {
|
|
8648
|
+
/** @constant */
|
|
8649
|
+
mode: "existing";
|
|
8650
|
+
/** Format: uuid */
|
|
8651
|
+
userId: string;
|
|
8652
|
+
} | {
|
|
8653
|
+
/** @constant */
|
|
8654
|
+
mode: "invite";
|
|
8655
|
+
/** Format: email */
|
|
8656
|
+
email: string;
|
|
8657
|
+
/** @enum {string} */
|
|
8658
|
+
role: "admin" | "member";
|
|
8659
|
+
};
|
|
8660
|
+
};
|
|
8661
|
+
};
|
|
8662
|
+
responses: {
|
|
8663
|
+
/** @description Filled open role */
|
|
8664
|
+
200: {
|
|
8665
|
+
headers: {
|
|
8666
|
+
[name: string]: unknown;
|
|
8667
|
+
};
|
|
8668
|
+
content: {
|
|
8669
|
+
"application/json": components["schemas"]["OpenRoleResponse"];
|
|
8670
|
+
};
|
|
8671
|
+
};
|
|
8672
|
+
};
|
|
8673
|
+
};
|
|
8460
8674
|
getPeopleOrgChart: {
|
|
8461
8675
|
parameters: {
|
|
8462
8676
|
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'],
|
package/src/identity/index.ts
CHANGED
|
@@ -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", () => {
|
|
@@ -10,6 +10,7 @@ describe("VIEW_SCOPE_MAP golden snapshot", () => {
|
|
|
10
10
|
"teamwork-member": "org.view_teamwork",
|
|
11
11
|
"company-md": "org.view_company_md",
|
|
12
12
|
"internal-admin": "internal.view_admin",
|
|
13
|
+
"execution-detail": "org.view_timeline",
|
|
13
14
|
teams: null,
|
|
14
15
|
chat: null,
|
|
15
16
|
settings: null,
|
|
@@ -28,6 +29,7 @@ describe("getViewScope", () => {
|
|
|
28
29
|
expect(getViewScope("teamwork")).toBe("org.view_teamwork");
|
|
29
30
|
expect(getViewScope("company-md")).toBe("org.view_company_md");
|
|
30
31
|
expect(getViewScope("internal-admin")).toBe("internal.view_admin");
|
|
32
|
+
expect(getViewScope("execution-detail")).toBe("org.view_timeline");
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
it("returns null for public views", () => {
|
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,6 +237,13 @@ 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,
|
|
@@ -259,6 +267,12 @@ export type {
|
|
|
259
267
|
OrgUnit,
|
|
260
268
|
OrgUnitTreeNode,
|
|
261
269
|
OrgUnitMembership,
|
|
270
|
+
OpenRole,
|
|
271
|
+
OpenRoleResponse,
|
|
272
|
+
OpenRoleListResponse,
|
|
273
|
+
CreateOpenRoleRequest,
|
|
274
|
+
UpdateOpenRoleStatusRequest,
|
|
275
|
+
FillOpenRoleRequest,
|
|
262
276
|
OrgUnitRelationship,
|
|
263
277
|
OrgLevelConfig,
|
|
264
278
|
OrgLevelIcon,
|
package/src/org/org-units.ts
CHANGED
|
@@ -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
|
/**
|
package/src/org/schemas.ts
CHANGED
|
@@ -855,6 +855,18 @@ export const OrgUnitMembershipSourceSchema = z.enum([
|
|
|
855
855
|
"hris",
|
|
856
856
|
]);
|
|
857
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Hiring lifecycle of an open role. See `OpenRoleStatus` in `./org-units` for
|
|
860
|
+
* the transition rules. Only `open` and `hiring` are "active" (render a card
|
|
861
|
+
* and count toward `openRoleCount`); `filled`/`closed` are terminal.
|
|
862
|
+
*/
|
|
863
|
+
export const OpenRoleStatusSchema = z.enum([
|
|
864
|
+
"open",
|
|
865
|
+
"hiring",
|
|
866
|
+
"filled",
|
|
867
|
+
"closed",
|
|
868
|
+
]);
|
|
869
|
+
|
|
858
870
|
export const OrgUnitErrorCodeSchema = z.enum([
|
|
859
871
|
"CYCLE_BLOCKED",
|
|
860
872
|
"DEPTH_EXCEEDED",
|
|
@@ -903,6 +915,12 @@ export const OrgUnitTreeNodeSchema = OrgUnitSchema.extend({
|
|
|
903
915
|
depth: z.number().int().min(1).max(5),
|
|
904
916
|
hasChildren: z.boolean(),
|
|
905
917
|
memberCount: z.number().int().min(0),
|
|
918
|
+
/**
|
|
919
|
+
* Active open roles (`open` + `hiring`) rolled up over this unit's subtree,
|
|
920
|
+
* the same way `memberCount` rolls up real members. Drives the "(N + M open)"
|
|
921
|
+
* count split in the people surfaces. `memberCount` stays real-members-only.
|
|
922
|
+
*/
|
|
923
|
+
openRoleCount: z.number().int().min(0),
|
|
906
924
|
missingAtNextLevel: MissingAtNextLevelSchema.nullable(),
|
|
907
925
|
});
|
|
908
926
|
|
|
@@ -919,6 +937,25 @@ export const OrgUnitMembershipSchema = z.object({
|
|
|
919
937
|
updatedAt: z.string(),
|
|
920
938
|
});
|
|
921
939
|
|
|
940
|
+
/**
|
|
941
|
+
* An open role — a persisted placeholder for the person who will sit in a unit.
|
|
942
|
+
* Carries unit membership (a seat) but never authority. `title` and
|
|
943
|
+
* `targetStartDate` are optional planning attributes. On `fill`, `filledByUserId`
|
|
944
|
+
* points at the seated user and a real `org_unit_membership` is created; the row
|
|
945
|
+
* is kept as staffing history. `targetStartDate` is an ISO date (YYYY-MM-DD).
|
|
946
|
+
*/
|
|
947
|
+
export const OpenRoleSchema = z.object({
|
|
948
|
+
id: z.string().uuid(),
|
|
949
|
+
orgId: z.string().uuid(),
|
|
950
|
+
unitId: z.string().uuid(),
|
|
951
|
+
title: z.string().nullable(),
|
|
952
|
+
targetStartDate: z.string().nullable(),
|
|
953
|
+
status: OpenRoleStatusSchema,
|
|
954
|
+
filledByUserId: z.string().uuid().nullable(),
|
|
955
|
+
createdAt: z.string(),
|
|
956
|
+
updatedAt: z.string(),
|
|
957
|
+
});
|
|
958
|
+
|
|
922
959
|
// ---------------------------------------------------------------------------
|
|
923
960
|
// Authority & Delegation vocabulary
|
|
924
961
|
//
|
|
@@ -1177,6 +1214,49 @@ export const OrgUnitMembershipListResponseSchema = z.object({
|
|
|
1177
1214
|
memberships: z.array(OrgUnitMembershipSchema),
|
|
1178
1215
|
});
|
|
1179
1216
|
|
|
1217
|
+
// ---------------------------------------------------------------------------
|
|
1218
|
+
// Open roles (PRD open-roles)
|
|
1219
|
+
// Request schemas are colocated here (a narrow deviation from ADR-CONT-044, as
|
|
1220
|
+
// with UpdateOrgUnitRequest) so the app's add-role form and the backend
|
|
1221
|
+
// validate against one published shape.
|
|
1222
|
+
// ---------------------------------------------------------------------------
|
|
1223
|
+
|
|
1224
|
+
export const OpenRoleResponseSchema = z.object({ openRole: OpenRoleSchema });
|
|
1225
|
+
|
|
1226
|
+
export const OpenRoleListResponseSchema = z.object({
|
|
1227
|
+
unitId: z.string().uuid(),
|
|
1228
|
+
openRoles: z.array(OpenRoleSchema),
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
/** ISO calendar date (YYYY-MM-DD) — version-agnostic vs `z.string().date()`. */
|
|
1232
|
+
const IsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
|
|
1233
|
+
message: "must be an ISO date (YYYY-MM-DD)",
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
export const CreateOpenRoleRequestSchema = z.object({
|
|
1237
|
+
title: z.string().min(1).max(255).nullable().optional(),
|
|
1238
|
+
targetStartDate: IsoDateSchema.nullable().optional(),
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
/** Only the non-terminal client transitions are accepted; `fill` is its own
|
|
1242
|
+
* endpoint and `open` is the seeded initial state, never set by the client. */
|
|
1243
|
+
export const UpdateOpenRoleStatusRequestSchema = z.object({
|
|
1244
|
+
status: z.enum(["hiring", "closed"]),
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Fill an open role by seating an existing org member (`userId`) or inviting a
|
|
1249
|
+
* new person by email. Exactly one branch — a discriminated union keyed on `mode`.
|
|
1250
|
+
*/
|
|
1251
|
+
export const FillOpenRoleRequestSchema = z.discriminatedUnion("mode", [
|
|
1252
|
+
z.object({ mode: z.literal("existing"), userId: z.string().uuid() }),
|
|
1253
|
+
z.object({
|
|
1254
|
+
mode: z.literal("invite"),
|
|
1255
|
+
email: z.string().email(),
|
|
1256
|
+
role: z.enum(["admin", "member"]),
|
|
1257
|
+
}),
|
|
1258
|
+
]);
|
|
1259
|
+
|
|
1180
1260
|
/**
|
|
1181
1261
|
* Effective manager of an org unit — a user whose membership (direct or
|
|
1182
1262
|
* inherited from an ancestor) grants the `orgUnit.{id}.manage` capability.
|
|
@@ -1249,6 +1329,14 @@ export type OrgUnitMembershipResponse = z.infer<
|
|
|
1249
1329
|
export type OrgUnitMembershipListResponse = z.infer<
|
|
1250
1330
|
typeof OrgUnitMembershipListResponseSchema
|
|
1251
1331
|
>;
|
|
1332
|
+
export type OpenRole = z.infer<typeof OpenRoleSchema>;
|
|
1333
|
+
export type OpenRoleResponse = z.infer<typeof OpenRoleResponseSchema>;
|
|
1334
|
+
export type OpenRoleListResponse = z.infer<typeof OpenRoleListResponseSchema>;
|
|
1335
|
+
export type CreateOpenRoleRequest = z.infer<typeof CreateOpenRoleRequestSchema>;
|
|
1336
|
+
export type UpdateOpenRoleStatusRequest = z.infer<
|
|
1337
|
+
typeof UpdateOpenRoleStatusRequestSchema
|
|
1338
|
+
>;
|
|
1339
|
+
export type FillOpenRoleRequest = z.infer<typeof FillOpenRoleRequestSchema>;
|
|
1252
1340
|
export type OrgUnitPermissionsEntry = z.infer<
|
|
1253
1341
|
typeof OrgUnitPermissionsEntrySchema
|
|
1254
1342
|
>;
|
package/src/org/view-scopes.ts
CHANGED
|
@@ -23,6 +23,11 @@ export const VIEW_SCOPE_MAP = {
|
|
|
23
23
|
"teamwork-member": "org.view_teamwork",
|
|
24
24
|
"company-md": "org.view_company_md",
|
|
25
25
|
"internal-admin": "internal.view_admin",
|
|
26
|
+
// `execution-detail` (/@org/executions/{id}) is gated behind the same scope as
|
|
27
|
+
// its only entry point, the timeline. Per-execution visibility is additionally
|
|
28
|
+
// enforced at the data layer (GET /summary + /result return 404 when the
|
|
29
|
+
// execution is not visible). See ADR-APP-045.
|
|
30
|
+
"execution-detail": "org.view_timeline",
|
|
26
31
|
// Public views (require only authentication)
|
|
27
32
|
// `teams`: team/directory visibility is membership-derived (ADR-BE-241 tier 1),
|
|
28
33
|
// not a scope. Repointed from the never-enforced `org.view_teams` to null when
|