@kernhq/module-hr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +662 -0
- package/README.md +71 -0
- package/dist/contract/capabilities.d.ts +45 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +114 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +74 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +63 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +457 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +381 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +174 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +209 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +2720 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +520 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +34 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/policy/calendar.d.ts +64 -0
- package/dist/policy/calendar.d.ts.map +1 -0
- package/dist/policy/calendar.js +113 -0
- package/dist/policy/calendar.js.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +141 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/packs/index.d.ts +50 -0
- package/dist/server/packs/index.d.ts.map +1 -0
- package/dist/server/packs/index.js +121 -0
- package/dist/server/packs/index.js.map +1 -0
- package/dist/server/router.d.ts +3095 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +1537 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +2828 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +313 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/db.d.ts +22 -0
- package/dist/server/services/db.d.ts.map +1 -0
- package/dist/server/services/db.js +23 -0
- package/dist/server/services/db.js.map +1 -0
- package/dist/server/services/people.d.ts +110 -0
- package/dist/server/services/people.d.ts.map +1 -0
- package/dist/server/services/people.js +182 -0
- package/dist/server/services/people.js.map +1 -0
- package/dist/server/services/resolve.d.ts +66 -0
- package/dist/server/services/resolve.d.ts.map +1 -0
- package/dist/server/services/resolve.js +145 -0
- package/dist/server/services/resolve.js.map +1 -0
- package/migrations/0000_init.sql +223 -0
- package/migrations/0001_rls.sql +141 -0
- package/migrations/meta/0000_snapshot.json +1715 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +59 -0
- package/src/client/index.ts +62 -0
- package/src/contract/capabilities.ts +117 -0
- package/src/contract/events.ts +84 -0
- package/src/contract/index.ts +14 -0
- package/src/contract/models.ts +437 -0
- package/src/contract/permissions.ts +217 -0
- package/src/contract/router.ts +611 -0
- package/src/contract/settings.ts +35 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { baseContract, PageInput, page, WorkspaceId } from '@kernhq/contracts'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import {
|
|
4
|
+
Calendar,
|
|
5
|
+
CalendarDayKind,
|
|
6
|
+
CostCenter,
|
|
7
|
+
CountryCode,
|
|
8
|
+
CustomFieldDef,
|
|
9
|
+
Employment,
|
|
10
|
+
EmploymentType,
|
|
11
|
+
IsoDate,
|
|
12
|
+
LegalEntity,
|
|
13
|
+
Office,
|
|
14
|
+
OfficeAssignment,
|
|
15
|
+
OfficeKind,
|
|
16
|
+
OrgUnit,
|
|
17
|
+
Person,
|
|
18
|
+
PersonDocument,
|
|
19
|
+
PersonResolution,
|
|
20
|
+
PersonSensitive,
|
|
21
|
+
PersonStatus,
|
|
22
|
+
Position,
|
|
23
|
+
RegionCode,
|
|
24
|
+
ResolvedCalendarDay,
|
|
25
|
+
TimeZone,
|
|
26
|
+
WorkingWeek,
|
|
27
|
+
} from './models.js'
|
|
28
|
+
|
|
29
|
+
const ws = z.object({ workspaceId: WorkspaceId })
|
|
30
|
+
const t = ['hr'] as const
|
|
31
|
+
const ok = z.object({ ok: z.literal(true) })
|
|
32
|
+
|
|
33
|
+
export const hrContract = {
|
|
34
|
+
// ---------------------------------------------------------------- people
|
|
35
|
+
people: {
|
|
36
|
+
list: baseContract
|
|
37
|
+
.route({ method: 'GET', path: '/people', tags: t })
|
|
38
|
+
.input(
|
|
39
|
+
ws.extend({
|
|
40
|
+
...PageInput.shape,
|
|
41
|
+
q: z.string().max(120).optional(),
|
|
42
|
+
officeId: z.uuid().optional(),
|
|
43
|
+
orgUnitId: z.uuid().optional(),
|
|
44
|
+
/** Include the whole subtree below `orgUnitId`, not just its direct members. */
|
|
45
|
+
includeDescendants: z.boolean().default(true),
|
|
46
|
+
positionId: z.uuid().optional(),
|
|
47
|
+
status: z.array(PersonStatus).optional(),
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
.output(page(Person)),
|
|
51
|
+
get: baseContract
|
|
52
|
+
.route({ method: 'GET', path: '/people/{personId}', tags: t })
|
|
53
|
+
.input(ws.extend({ personId: z.uuid() }))
|
|
54
|
+
.output(Person),
|
|
55
|
+
/**
|
|
56
|
+
* The caller's own record.
|
|
57
|
+
*
|
|
58
|
+
* No permission: everybody has one and everybody may read it. Returns null rather than erroring
|
|
59
|
+
* when the signed-in user has no HR record — plenty of members are not employees, and that is
|
|
60
|
+
* an ordinary answer rather than a failure.
|
|
61
|
+
*/
|
|
62
|
+
me: baseContract
|
|
63
|
+
.route({ method: 'GET', path: '/people/me', tags: t })
|
|
64
|
+
.input(ws)
|
|
65
|
+
.output(Person.nullable()),
|
|
66
|
+
create: baseContract
|
|
67
|
+
.route({ method: 'POST', path: '/people', tags: t })
|
|
68
|
+
.input(
|
|
69
|
+
ws.extend({
|
|
70
|
+
displayName: z.string().min(1).max(160),
|
|
71
|
+
userId: z.uuid().nullish(),
|
|
72
|
+
employeeNo: z.string().max(32).nullish(),
|
|
73
|
+
workEmail: z.email().max(254).nullish(),
|
|
74
|
+
hiredOn: IsoDate.nullish(),
|
|
75
|
+
officeId: z.uuid().nullish(),
|
|
76
|
+
orgUnitId: z.uuid().nullish(),
|
|
77
|
+
positionId: z.uuid().nullish(),
|
|
78
|
+
managerPersonId: z.uuid().nullish(),
|
|
79
|
+
employmentType: EmploymentType.default('full_time'),
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
.output(Person),
|
|
83
|
+
update: baseContract
|
|
84
|
+
.route({ method: 'PATCH', path: '/people/{personId}', tags: t })
|
|
85
|
+
.input(
|
|
86
|
+
ws.extend({
|
|
87
|
+
personId: z.uuid(),
|
|
88
|
+
displayName: z.string().min(1).max(160).optional(),
|
|
89
|
+
workEmail: z.email().max(254).nullish(),
|
|
90
|
+
personalEmail: z.email().max(254).nullish(),
|
|
91
|
+
phone: z.string().max(32).nullish(),
|
|
92
|
+
photoFileId: z.uuid().nullish(),
|
|
93
|
+
timezone: TimeZone.nullish(),
|
|
94
|
+
custom: z.record(z.string(), z.unknown()).optional(),
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
.output(Person),
|
|
98
|
+
/** Ends employment. Keeps the record — a terminated person is history, not a deletion. */
|
|
99
|
+
offboard: baseContract
|
|
100
|
+
.route({ method: 'POST', path: '/people/{personId}/offboard', tags: t })
|
|
101
|
+
.input(ws.extend({ personId: z.uuid(), on: IsoDate, reason: z.string().max(200).optional() }))
|
|
102
|
+
.output(Person),
|
|
103
|
+
history: baseContract
|
|
104
|
+
.route({ method: 'GET', path: '/people/{personId}/history', tags: t })
|
|
105
|
+
.input(ws.extend({ personId: z.uuid(), ...PageInput.shape }))
|
|
106
|
+
.output(
|
|
107
|
+
page(
|
|
108
|
+
z.object({
|
|
109
|
+
id: z.uuid(),
|
|
110
|
+
field: z.string(),
|
|
111
|
+
from: z.unknown().nullable(),
|
|
112
|
+
to: z.unknown().nullable(),
|
|
113
|
+
at: z.string(),
|
|
114
|
+
actorId: z.uuid().nullable(),
|
|
115
|
+
source: z.string(),
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
sensitive: {
|
|
120
|
+
get: baseContract
|
|
121
|
+
.route({ method: 'GET', path: '/people/{personId}/sensitive', tags: t })
|
|
122
|
+
.input(ws.extend({ personId: z.uuid() }))
|
|
123
|
+
.output(PersonSensitive),
|
|
124
|
+
update: baseContract
|
|
125
|
+
.route({ method: 'PATCH', path: '/people/{personId}/sensitive', tags: t })
|
|
126
|
+
.input(
|
|
127
|
+
ws.extend({
|
|
128
|
+
personId: z.uuid(),
|
|
129
|
+
nationalId: z.string().max(64).nullish(),
|
|
130
|
+
birthDate: IsoDate.nullish(),
|
|
131
|
+
iban: z.string().max(48).nullish(),
|
|
132
|
+
emergencyContact: z
|
|
133
|
+
.object({
|
|
134
|
+
name: z.string().max(160),
|
|
135
|
+
relationship: z.string().max(64).optional(),
|
|
136
|
+
phone: z.string().max(32),
|
|
137
|
+
})
|
|
138
|
+
.nullish(),
|
|
139
|
+
}),
|
|
140
|
+
)
|
|
141
|
+
.output(PersonSensitive),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------- employment
|
|
146
|
+
employment: {
|
|
147
|
+
current: baseContract
|
|
148
|
+
.route({ method: 'GET', path: '/people/{personId}/employment', tags: t })
|
|
149
|
+
.input(ws.extend({ personId: z.uuid(), on: IsoDate.optional() }))
|
|
150
|
+
.output(Employment.nullable()),
|
|
151
|
+
history: baseContract
|
|
152
|
+
.route({ method: 'GET', path: '/people/{personId}/employment/history', tags: t })
|
|
153
|
+
.input(ws.extend({ personId: z.uuid() }))
|
|
154
|
+
.output(z.array(Employment)),
|
|
155
|
+
/**
|
|
156
|
+
* Records a change from a date. Closes the current row and opens a new one — never an update.
|
|
157
|
+
*
|
|
158
|
+
* `effectiveFrom` may be in the past: a promotion agreed in March and entered in May is normal,
|
|
159
|
+
* and the record has to say March.
|
|
160
|
+
*/
|
|
161
|
+
change: baseContract
|
|
162
|
+
.route({ method: 'POST', path: '/people/{personId}/employment', tags: t })
|
|
163
|
+
.input(
|
|
164
|
+
ws.extend({
|
|
165
|
+
personId: z.uuid(),
|
|
166
|
+
effectiveFrom: IsoDate,
|
|
167
|
+
orgUnitId: z.uuid().nullish(),
|
|
168
|
+
positionId: z.uuid().nullish(),
|
|
169
|
+
legalEntityId: z.uuid().nullish(),
|
|
170
|
+
costCenterId: z.uuid().nullish(),
|
|
171
|
+
managerPersonId: z.uuid().nullish(),
|
|
172
|
+
employmentType: EmploymentType.optional(),
|
|
173
|
+
fte: z.number().min(0).max(1).optional(),
|
|
174
|
+
contractHoursWeek: z.number().min(0).max(168).nullish(),
|
|
175
|
+
reason: z.string().max(200).nullish(),
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
.output(Employment),
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------- org
|
|
182
|
+
org: {
|
|
183
|
+
units: {
|
|
184
|
+
tree: baseContract
|
|
185
|
+
.route({ method: 'GET', path: '/org/units', tags: t })
|
|
186
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
187
|
+
.output(z.array(OrgUnit.extend({ headcount: z.number().int().nonnegative() }))),
|
|
188
|
+
create: baseContract
|
|
189
|
+
.route({ method: 'POST', path: '/org/units', tags: t })
|
|
190
|
+
.input(
|
|
191
|
+
ws.extend({
|
|
192
|
+
name: z.string().min(1).max(160),
|
|
193
|
+
parentId: z.uuid().nullish(),
|
|
194
|
+
code: z.string().max(32).nullish(),
|
|
195
|
+
headPersonId: z.uuid().nullish(),
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
.output(OrgUnit),
|
|
199
|
+
update: baseContract
|
|
200
|
+
.route({ method: 'PATCH', path: '/org/units/{unitId}', tags: t })
|
|
201
|
+
.input(
|
|
202
|
+
ws.extend({
|
|
203
|
+
unitId: z.uuid(),
|
|
204
|
+
name: z.string().min(1).max(160).optional(),
|
|
205
|
+
code: z.string().max(32).nullish(),
|
|
206
|
+
headPersonId: z.uuid().nullish(),
|
|
207
|
+
}),
|
|
208
|
+
)
|
|
209
|
+
.output(OrgUnit),
|
|
210
|
+
/** Reparents a unit and rewrites the ltree path of everything beneath it. */
|
|
211
|
+
move: baseContract
|
|
212
|
+
.route({ method: 'POST', path: '/org/units/{unitId}/move', tags: t })
|
|
213
|
+
.input(ws.extend({ unitId: z.uuid(), parentId: z.uuid().nullable() }))
|
|
214
|
+
.output(z.array(OrgUnit)),
|
|
215
|
+
archive: baseContract
|
|
216
|
+
.route({ method: 'DELETE', path: '/org/units/{unitId}', tags: t })
|
|
217
|
+
.input(ws.extend({ unitId: z.uuid() }))
|
|
218
|
+
.output(ok),
|
|
219
|
+
},
|
|
220
|
+
positions: {
|
|
221
|
+
list: baseContract
|
|
222
|
+
.route({ method: 'GET', path: '/org/positions', tags: t })
|
|
223
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
224
|
+
.output(z.array(Position)),
|
|
225
|
+
create: baseContract
|
|
226
|
+
.route({ method: 'POST', path: '/org/positions', tags: t })
|
|
227
|
+
.input(
|
|
228
|
+
ws.extend({
|
|
229
|
+
title: z.string().min(1).max(160),
|
|
230
|
+
code: z.string().max(32).nullish(),
|
|
231
|
+
jobFamily: z.string().max(64).nullish(),
|
|
232
|
+
level: z.string().max(32).nullish(),
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
.output(Position),
|
|
236
|
+
update: baseContract
|
|
237
|
+
.route({ method: 'PATCH', path: '/org/positions/{positionId}', tags: t })
|
|
238
|
+
.input(
|
|
239
|
+
ws.extend({
|
|
240
|
+
positionId: z.uuid(),
|
|
241
|
+
title: z.string().min(1).max(160).optional(),
|
|
242
|
+
code: z.string().max(32).nullish(),
|
|
243
|
+
jobFamily: z.string().max(64).nullish(),
|
|
244
|
+
level: z.string().max(32).nullish(),
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
.output(Position),
|
|
248
|
+
archive: baseContract
|
|
249
|
+
.route({ method: 'DELETE', path: '/org/positions/{positionId}', tags: t })
|
|
250
|
+
.input(ws.extend({ positionId: z.uuid() }))
|
|
251
|
+
.output(ok),
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- offices
|
|
256
|
+
offices: {
|
|
257
|
+
list: baseContract
|
|
258
|
+
.route({ method: 'GET', path: '/offices', tags: t })
|
|
259
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
260
|
+
.output(z.array(Office.extend({ headcount: z.number().int().nonnegative() }))),
|
|
261
|
+
get: baseContract
|
|
262
|
+
.route({ method: 'GET', path: '/offices/{officeId}', tags: t })
|
|
263
|
+
.input(ws.extend({ officeId: z.uuid() }))
|
|
264
|
+
.output(Office),
|
|
265
|
+
create: baseContract
|
|
266
|
+
.route({ method: 'POST', path: '/offices', tags: t })
|
|
267
|
+
.input(
|
|
268
|
+
ws.extend({
|
|
269
|
+
name: z.string().min(1).max(160),
|
|
270
|
+
kind: OfficeKind.default('branch'),
|
|
271
|
+
country: CountryCode,
|
|
272
|
+
region: RegionCode.nullish(),
|
|
273
|
+
city: z.string().max(120).nullish(),
|
|
274
|
+
timezone: TimeZone,
|
|
275
|
+
code: z.string().max(32).nullish(),
|
|
276
|
+
parentOfficeId: z.uuid().nullish(),
|
|
277
|
+
legalEntityId: z.uuid().nullish(),
|
|
278
|
+
/**
|
|
279
|
+
* Seed the office's calendar from a country pack. Omit to share the workspace default.
|
|
280
|
+
* The pack is copied as a *base* the office's calendar extends, never inlined.
|
|
281
|
+
*/
|
|
282
|
+
seedCalendarFromPack: z.boolean().default(true),
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
.output(Office),
|
|
286
|
+
update: baseContract
|
|
287
|
+
.route({ method: 'PATCH', path: '/offices/{officeId}', tags: t })
|
|
288
|
+
.input(
|
|
289
|
+
ws.extend({
|
|
290
|
+
officeId: z.uuid(),
|
|
291
|
+
name: z.string().min(1).max(160).optional(),
|
|
292
|
+
kind: OfficeKind.optional(),
|
|
293
|
+
country: CountryCode.optional(),
|
|
294
|
+
region: RegionCode.nullish(),
|
|
295
|
+
city: z.string().max(120).nullish(),
|
|
296
|
+
timezone: TimeZone.optional(),
|
|
297
|
+
calendarId: z.uuid().nullish(),
|
|
298
|
+
legalEntityId: z.uuid().nullish(),
|
|
299
|
+
headPersonId: z.uuid().nullish(),
|
|
300
|
+
code: z.string().max(32).nullish(),
|
|
301
|
+
}),
|
|
302
|
+
)
|
|
303
|
+
.output(Office),
|
|
304
|
+
archive: baseContract
|
|
305
|
+
.route({ method: 'DELETE', path: '/offices/{officeId}', tags: t })
|
|
306
|
+
.input(ws.extend({ officeId: z.uuid() }))
|
|
307
|
+
.output(ok),
|
|
308
|
+
/** Moves the default flag. The old default keeps its people; only new arrivals change. */
|
|
309
|
+
setDefault: baseContract
|
|
310
|
+
.route({ method: 'POST', path: '/offices/{officeId}/default', tags: t })
|
|
311
|
+
.input(ws.extend({ officeId: z.uuid() }))
|
|
312
|
+
.output(Office),
|
|
313
|
+
people: baseContract
|
|
314
|
+
.route({ method: 'GET', path: '/offices/{officeId}/people', tags: t })
|
|
315
|
+
.input(ws.extend({ officeId: z.uuid(), ...PageInput.shape, primaryOnly: z.boolean().default(false) }))
|
|
316
|
+
.output(page(Person.extend({ isPrimaryHere: z.boolean() }))),
|
|
317
|
+
assign: baseContract
|
|
318
|
+
.route({ method: 'POST', path: '/offices/{officeId}/people', tags: t })
|
|
319
|
+
.input(
|
|
320
|
+
ws.extend({
|
|
321
|
+
officeId: z.uuid(),
|
|
322
|
+
personId: z.uuid(),
|
|
323
|
+
isPrimary: z.boolean().default(true),
|
|
324
|
+
effectiveFrom: IsoDate,
|
|
325
|
+
reason: z.string().max(200).nullish(),
|
|
326
|
+
}),
|
|
327
|
+
)
|
|
328
|
+
.output(z.array(OfficeAssignment)),
|
|
329
|
+
unassign: baseContract
|
|
330
|
+
.route({ method: 'DELETE', path: '/offices/{officeId}/people/{personId}', tags: t })
|
|
331
|
+
.input(ws.extend({ officeId: z.uuid(), personId: z.uuid(), effectiveTo: IsoDate }))
|
|
332
|
+
.output(ok),
|
|
333
|
+
/**
|
|
334
|
+
* What actually applies to this person on this date, and which rung of the ladder answered.
|
|
335
|
+
*
|
|
336
|
+
* Not behind the `offices` capability: a workspace with one office still has a ladder, and this
|
|
337
|
+
* is the first thing anybody reaches for when a holiday or a policy looks wrong. It is the
|
|
338
|
+
* difference between answering a support question and opening a database session.
|
|
339
|
+
*/
|
|
340
|
+
resolveFor: baseContract
|
|
341
|
+
.route({ method: 'GET', path: '/people/{personId}/resolution', tags: t })
|
|
342
|
+
.input(ws.extend({ personId: z.uuid(), on: IsoDate.optional() }))
|
|
343
|
+
.output(PersonResolution),
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------- legal entities
|
|
347
|
+
entities: {
|
|
348
|
+
list: baseContract
|
|
349
|
+
.route({ method: 'GET', path: '/entities', tags: t })
|
|
350
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
351
|
+
.output(z.array(LegalEntity)),
|
|
352
|
+
get: baseContract
|
|
353
|
+
.route({ method: 'GET', path: '/entities/{entityId}', tags: t })
|
|
354
|
+
.input(ws.extend({ entityId: z.uuid() }))
|
|
355
|
+
.output(LegalEntity),
|
|
356
|
+
create: baseContract
|
|
357
|
+
.route({ method: 'POST', path: '/entities', tags: t })
|
|
358
|
+
.input(
|
|
359
|
+
ws.extend({
|
|
360
|
+
name: z.string().min(1).max(200),
|
|
361
|
+
country: CountryCode,
|
|
362
|
+
registrationNo: z.string().max(64).nullish(),
|
|
363
|
+
taxNo: z.string().max(64).nullish(),
|
|
364
|
+
currency: z.string().length(3).nullish(),
|
|
365
|
+
}),
|
|
366
|
+
)
|
|
367
|
+
.output(LegalEntity),
|
|
368
|
+
update: baseContract
|
|
369
|
+
.route({ method: 'PATCH', path: '/entities/{entityId}', tags: t })
|
|
370
|
+
.input(
|
|
371
|
+
ws.extend({
|
|
372
|
+
entityId: z.uuid(),
|
|
373
|
+
name: z.string().min(1).max(200).optional(),
|
|
374
|
+
country: CountryCode.optional(),
|
|
375
|
+
registrationNo: z.string().max(64).nullish(),
|
|
376
|
+
taxNo: z.string().max(64).nullish(),
|
|
377
|
+
currency: z.string().length(3).nullish(),
|
|
378
|
+
}),
|
|
379
|
+
)
|
|
380
|
+
.output(LegalEntity),
|
|
381
|
+
archive: baseContract
|
|
382
|
+
.route({ method: 'DELETE', path: '/entities/{entityId}', tags: t })
|
|
383
|
+
.input(ws.extend({ entityId: z.uuid() }))
|
|
384
|
+
.output(ok),
|
|
385
|
+
costCenters: {
|
|
386
|
+
list: baseContract
|
|
387
|
+
.route({ method: 'GET', path: '/cost-centers', tags: t })
|
|
388
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
389
|
+
.output(z.array(CostCenter)),
|
|
390
|
+
create: baseContract
|
|
391
|
+
.route({ method: 'POST', path: '/cost-centers', tags: t })
|
|
392
|
+
.input(
|
|
393
|
+
ws.extend({
|
|
394
|
+
code: z.string().min(1).max(32),
|
|
395
|
+
name: z.string().min(1).max(160),
|
|
396
|
+
officeId: z.uuid().nullish(),
|
|
397
|
+
orgUnitId: z.uuid().nullish(),
|
|
398
|
+
legalEntityId: z.uuid().nullish(),
|
|
399
|
+
}),
|
|
400
|
+
)
|
|
401
|
+
.output(CostCenter),
|
|
402
|
+
archive: baseContract
|
|
403
|
+
.route({ method: 'DELETE', path: '/cost-centers/{costCenterId}', tags: t })
|
|
404
|
+
.input(ws.extend({ costCenterId: z.uuid() }))
|
|
405
|
+
.output(ok),
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------- calendars
|
|
410
|
+
calendars: {
|
|
411
|
+
list: baseContract
|
|
412
|
+
.route({ method: 'GET', path: '/calendars', tags: t })
|
|
413
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
414
|
+
.output(z.array(Calendar.extend({ officeIds: z.array(z.uuid()) }))),
|
|
415
|
+
get: baseContract
|
|
416
|
+
.route({ method: 'GET', path: '/calendars/{calendarId}', tags: t })
|
|
417
|
+
.input(ws.extend({ calendarId: z.uuid() }))
|
|
418
|
+
.output(Calendar),
|
|
419
|
+
create: baseContract
|
|
420
|
+
.route({ method: 'POST', path: '/calendars', tags: t })
|
|
421
|
+
.input(
|
|
422
|
+
ws.extend({
|
|
423
|
+
name: z.string().min(1).max(160),
|
|
424
|
+
/** Build on a country pack. Its days stay `pack` and upgrade cleanly. */
|
|
425
|
+
extendsId: z.uuid().nullish(),
|
|
426
|
+
country: CountryCode.nullish(),
|
|
427
|
+
region: RegionCode.nullish(),
|
|
428
|
+
workingWeek: WorkingWeek.optional(),
|
|
429
|
+
}),
|
|
430
|
+
)
|
|
431
|
+
.output(Calendar),
|
|
432
|
+
update: baseContract
|
|
433
|
+
.route({ method: 'PATCH', path: '/calendars/{calendarId}', tags: t })
|
|
434
|
+
.input(
|
|
435
|
+
ws.extend({
|
|
436
|
+
calendarId: z.uuid(),
|
|
437
|
+
name: z.string().min(1).max(160).optional(),
|
|
438
|
+
workingWeek: WorkingWeek.optional(),
|
|
439
|
+
extendsId: z.uuid().nullish(),
|
|
440
|
+
}),
|
|
441
|
+
)
|
|
442
|
+
.output(Calendar),
|
|
443
|
+
archive: baseContract
|
|
444
|
+
.route({ method: 'DELETE', path: '/calendars/{calendarId}', tags: t })
|
|
445
|
+
.input(ws.extend({ calendarId: z.uuid() }))
|
|
446
|
+
.output(ok),
|
|
447
|
+
days: {
|
|
448
|
+
/**
|
|
449
|
+
* The composed calendar: the pack's days and this calendar's own, each labelled with where it
|
|
450
|
+
* came from and whether it overrides something below it.
|
|
451
|
+
*/
|
|
452
|
+
list: baseContract
|
|
453
|
+
.route({ method: 'GET', path: '/calendars/{calendarId}/days', tags: t })
|
|
454
|
+
.input(ws.extend({ calendarId: z.uuid(), from: IsoDate, to: IsoDate }))
|
|
455
|
+
.output(z.array(ResolvedCalendarDay)),
|
|
456
|
+
add: baseContract
|
|
457
|
+
.route({ method: 'POST', path: '/calendars/{calendarId}/days', tags: t })
|
|
458
|
+
.input(
|
|
459
|
+
ws.extend({
|
|
460
|
+
calendarId: z.uuid(),
|
|
461
|
+
date: IsoDate,
|
|
462
|
+
name: z.string().min(1).max(160),
|
|
463
|
+
kind: CalendarDayKind.default('company_closure'),
|
|
464
|
+
workingFraction: z.number().min(0).max(1).default(0),
|
|
465
|
+
paid: z.boolean().default(true),
|
|
466
|
+
note: z.string().max(500).nullish(),
|
|
467
|
+
}),
|
|
468
|
+
)
|
|
469
|
+
.output(ResolvedCalendarDay),
|
|
470
|
+
update: baseContract
|
|
471
|
+
.route({ method: 'PATCH', path: '/calendars/{calendarId}/days/{dayId}', tags: t })
|
|
472
|
+
.input(
|
|
473
|
+
ws.extend({
|
|
474
|
+
calendarId: z.uuid(),
|
|
475
|
+
dayId: z.uuid(),
|
|
476
|
+
name: z.string().min(1).max(160).optional(),
|
|
477
|
+
kind: CalendarDayKind.optional(),
|
|
478
|
+
workingFraction: z.number().min(0).max(1).optional(),
|
|
479
|
+
paid: z.boolean().optional(),
|
|
480
|
+
note: z.string().max(500).nullish(),
|
|
481
|
+
}),
|
|
482
|
+
)
|
|
483
|
+
.output(ResolvedCalendarDay),
|
|
484
|
+
/**
|
|
485
|
+
* Removes a day.
|
|
486
|
+
*
|
|
487
|
+
* A `custom` day is deleted. A `pack` day cannot be — it belongs to the pack, and deleting it
|
|
488
|
+
* would only bring it back on the next upgrade — so this writes a suppressing `custom` row
|
|
489
|
+
* over it instead, and says so in `suppressed`.
|
|
490
|
+
*/
|
|
491
|
+
remove: baseContract
|
|
492
|
+
.route({ method: 'DELETE', path: '/calendars/{calendarId}/days/{dayId}', tags: t })
|
|
493
|
+
.input(ws.extend({ calendarId: z.uuid(), dayId: z.uuid() }))
|
|
494
|
+
.output(z.object({ ok: z.literal(true), suppressed: z.boolean() })),
|
|
495
|
+
},
|
|
496
|
+
pack: {
|
|
497
|
+
/** What applying this pack would add, change and remove — and what it would leave alone. */
|
|
498
|
+
preview: baseContract
|
|
499
|
+
.route({ method: 'POST', path: '/calendars/{calendarId}/pack/preview', tags: t })
|
|
500
|
+
.input(ws.extend({ calendarId: z.uuid(), packKey: z.string().max(32), year: z.number().int() }))
|
|
501
|
+
.output(
|
|
502
|
+
z.object({
|
|
503
|
+
packKey: z.string(),
|
|
504
|
+
packVersion: z.string(),
|
|
505
|
+
added: z.array(z.object({ date: IsoDate, name: z.string() })),
|
|
506
|
+
changed: z.array(z.object({ date: IsoDate, name: z.string(), was: z.string() })),
|
|
507
|
+
removed: z.array(z.object({ date: IsoDate, name: z.string() })),
|
|
508
|
+
/** Days HR added themselves. Always untouched; listed so the dialog can say so. */
|
|
509
|
+
keptCustom: z.array(z.object({ date: IsoDate, name: z.string() })),
|
|
510
|
+
}),
|
|
511
|
+
),
|
|
512
|
+
apply: baseContract
|
|
513
|
+
.route({ method: 'POST', path: '/calendars/{calendarId}/pack/apply', tags: t })
|
|
514
|
+
.input(ws.extend({ calendarId: z.uuid(), packKey: z.string().max(32), year: z.number().int() }))
|
|
515
|
+
.output(
|
|
516
|
+
z.object({ ok: z.literal(true), added: z.number(), changed: z.number(), removed: z.number() }),
|
|
517
|
+
),
|
|
518
|
+
},
|
|
519
|
+
/**
|
|
520
|
+
* How many working days a range holds for a given person, honouring their office's calendar,
|
|
521
|
+
* working week, half-days and closures.
|
|
522
|
+
*
|
|
523
|
+
* The one computation leave, attendance and reporting all need. Exposed on the API as well as
|
|
524
|
+
* through `kernel.call` so a screen can show the number before anything is submitted.
|
|
525
|
+
*/
|
|
526
|
+
workingDays: baseContract
|
|
527
|
+
.route({ method: 'GET', path: '/calendars/working-days', tags: t })
|
|
528
|
+
.input(
|
|
529
|
+
ws.extend({
|
|
530
|
+
personId: z.uuid().optional(),
|
|
531
|
+
calendarId: z.uuid().optional(),
|
|
532
|
+
from: IsoDate,
|
|
533
|
+
to: IsoDate,
|
|
534
|
+
}),
|
|
535
|
+
)
|
|
536
|
+
.output(
|
|
537
|
+
z.object({
|
|
538
|
+
days: z.number(),
|
|
539
|
+
breakdown: z.array(
|
|
540
|
+
z.object({ date: IsoDate, fraction: z.number(), reason: z.string().nullable() }),
|
|
541
|
+
),
|
|
542
|
+
}),
|
|
543
|
+
),
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------- documents
|
|
547
|
+
documents: {
|
|
548
|
+
list: baseContract
|
|
549
|
+
.route({ method: 'GET', path: '/people/{personId}/documents', tags: t })
|
|
550
|
+
.input(ws.extend({ personId: z.uuid() }))
|
|
551
|
+
.output(z.array(PersonDocument)),
|
|
552
|
+
attach: baseContract
|
|
553
|
+
.route({ method: 'POST', path: '/people/{personId}/documents', tags: t })
|
|
554
|
+
.input(
|
|
555
|
+
ws.extend({
|
|
556
|
+
personId: z.uuid(),
|
|
557
|
+
fileId: z.uuid(),
|
|
558
|
+
name: z.string().min(1).max(200),
|
|
559
|
+
kind: z.string().max(48).default('other'),
|
|
560
|
+
issuedOn: IsoDate.nullish(),
|
|
561
|
+
expiresOn: IsoDate.nullish(),
|
|
562
|
+
}),
|
|
563
|
+
)
|
|
564
|
+
.output(PersonDocument),
|
|
565
|
+
remove: baseContract
|
|
566
|
+
.route({ method: 'DELETE', path: '/people/{personId}/documents/{documentId}', tags: t })
|
|
567
|
+
.input(ws.extend({ personId: z.uuid(), documentId: z.uuid() }))
|
|
568
|
+
.output(ok),
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------- custom fields
|
|
572
|
+
fields: {
|
|
573
|
+
list: baseContract
|
|
574
|
+
.route({ method: 'GET', path: '/fields', tags: t })
|
|
575
|
+
.input(ws.extend({ includeArchived: z.boolean().default(false) }))
|
|
576
|
+
.output(z.array(CustomFieldDef)),
|
|
577
|
+
create: baseContract
|
|
578
|
+
.route({ method: 'POST', path: '/fields', tags: t })
|
|
579
|
+
.input(
|
|
580
|
+
ws.extend({
|
|
581
|
+
key: z.string().min(1).max(48),
|
|
582
|
+
name: z.string().min(1).max(120),
|
|
583
|
+
type: CustomFieldDef.shape.type,
|
|
584
|
+
options: CustomFieldDef.shape.options.optional(),
|
|
585
|
+
required: z.boolean().default(false),
|
|
586
|
+
sensitive: z.boolean().default(false),
|
|
587
|
+
section: CustomFieldDef.shape.section.default('profile'),
|
|
588
|
+
}),
|
|
589
|
+
)
|
|
590
|
+
.output(CustomFieldDef),
|
|
591
|
+
update: baseContract
|
|
592
|
+
.route({ method: 'PATCH', path: '/fields/{fieldId}', tags: t })
|
|
593
|
+
.input(
|
|
594
|
+
ws.extend({
|
|
595
|
+
fieldId: z.uuid(),
|
|
596
|
+
name: z.string().min(1).max(120).optional(),
|
|
597
|
+
options: CustomFieldDef.shape.options.optional(),
|
|
598
|
+
required: z.boolean().optional(),
|
|
599
|
+
sensitive: z.boolean().optional(),
|
|
600
|
+
section: CustomFieldDef.shape.section.optional(),
|
|
601
|
+
order: z.number().int().optional(),
|
|
602
|
+
}),
|
|
603
|
+
)
|
|
604
|
+
.output(CustomFieldDef),
|
|
605
|
+
archive: baseContract
|
|
606
|
+
.route({ method: 'DELETE', path: '/fields/{fieldId}', tags: t })
|
|
607
|
+
.input(ws.extend({ fieldId: z.uuid() }))
|
|
608
|
+
.output(ok),
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
export type HrContract = typeof hrContract
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { CountryCode } from './models.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Workspace-level settings for HR.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately small. Almost everything an administrator can configure belongs to an office, a
|
|
8
|
+
* calendar or a policy record, because those are the things that differ between two groups of people
|
|
9
|
+
* in the same company. What is left here is genuinely workspace-wide.
|
|
10
|
+
*
|
|
11
|
+
* Note what is *not* here: the capability switches. Those live under a reserved `$capabilities` key
|
|
12
|
+
* that the platform owns, not in this schema — which is why turning one off cannot collide with a
|
|
13
|
+
* settings field and cannot be dropped by a settings round-trip.
|
|
14
|
+
*/
|
|
15
|
+
export const HrSettings = z.object({
|
|
16
|
+
/**
|
|
17
|
+
* The country the first office is built from, and the default for the next one.
|
|
18
|
+
*
|
|
19
|
+
* A seed, not a constraint. Once offices exist, each carries its own country and this is only
|
|
20
|
+
* consulted when creating one — a workspace headquartered in Turkey with a Dutch branch is
|
|
21
|
+
* ordinary, and nothing about the Dutch branch consults this value.
|
|
22
|
+
*/
|
|
23
|
+
country: CountryCode.default('TR'),
|
|
24
|
+
/** Employee numbers are generated from this when a person is created without one. */
|
|
25
|
+
employeeNumberPrefix: z.string().max(8).default(''),
|
|
26
|
+
employeeNumberNext: z.number().int().min(1).default(1),
|
|
27
|
+
/**
|
|
28
|
+
* Whether a member who is not in HR can see the directory at all.
|
|
29
|
+
*
|
|
30
|
+
* Some companies publish their org chart to everyone; some treat it as HR-only. This is coarser
|
|
31
|
+
* than the permission and sits above it: off, and `hr.person.view` is not enough on its own.
|
|
32
|
+
*/
|
|
33
|
+
directoryVisibleToMembers: z.boolean().default(true),
|
|
34
|
+
})
|
|
35
|
+
export type HrSettings = z.infer<typeof HrSettings>
|