@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.
Files changed (75) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +71 -0
  3. package/dist/contract/capabilities.d.ts +45 -0
  4. package/dist/contract/capabilities.d.ts.map +1 -0
  5. package/dist/contract/capabilities.js +114 -0
  6. package/dist/contract/capabilities.js.map +1 -0
  7. package/dist/contract/events.d.ts +74 -0
  8. package/dist/contract/events.d.ts.map +1 -0
  9. package/dist/contract/events.js +63 -0
  10. package/dist/contract/events.js.map +1 -0
  11. package/dist/contract/index.d.ts +15 -0
  12. package/dist/contract/index.d.ts.map +1 -0
  13. package/dist/contract/index.js +15 -0
  14. package/dist/contract/index.js.map +1 -0
  15. package/dist/contract/models.d.ts +457 -0
  16. package/dist/contract/models.d.ts.map +1 -0
  17. package/dist/contract/models.js +381 -0
  18. package/dist/contract/models.js.map +1 -0
  19. package/dist/contract/permissions.d.ts +174 -0
  20. package/dist/contract/permissions.d.ts.map +1 -0
  21. package/dist/contract/permissions.js +209 -0
  22. package/dist/contract/permissions.js.map +1 -0
  23. package/dist/contract/router.d.ts +2720 -0
  24. package/dist/contract/router.d.ts.map +1 -0
  25. package/dist/contract/router.js +520 -0
  26. package/dist/contract/router.js.map +1 -0
  27. package/dist/contract/settings.d.ts +20 -0
  28. package/dist/contract/settings.d.ts.map +1 -0
  29. package/dist/contract/settings.js +34 -0
  30. package/dist/contract/settings.js.map +1 -0
  31. package/dist/policy/calendar.d.ts +64 -0
  32. package/dist/policy/calendar.d.ts.map +1 -0
  33. package/dist/policy/calendar.js +113 -0
  34. package/dist/policy/calendar.js.map +1 -0
  35. package/dist/server/index.d.ts +8 -0
  36. package/dist/server/index.d.ts.map +1 -0
  37. package/dist/server/index.js +141 -0
  38. package/dist/server/index.js.map +1 -0
  39. package/dist/server/packs/index.d.ts +50 -0
  40. package/dist/server/packs/index.d.ts.map +1 -0
  41. package/dist/server/packs/index.js +121 -0
  42. package/dist/server/packs/index.js.map +1 -0
  43. package/dist/server/router.d.ts +3095 -0
  44. package/dist/server/router.d.ts.map +1 -0
  45. package/dist/server/router.js +1537 -0
  46. package/dist/server/router.js.map +1 -0
  47. package/dist/server/schema.d.ts +2828 -0
  48. package/dist/server/schema.d.ts.map +1 -0
  49. package/dist/server/schema.js +313 -0
  50. package/dist/server/schema.js.map +1 -0
  51. package/dist/server/services/db.d.ts +22 -0
  52. package/dist/server/services/db.d.ts.map +1 -0
  53. package/dist/server/services/db.js +23 -0
  54. package/dist/server/services/db.js.map +1 -0
  55. package/dist/server/services/people.d.ts +110 -0
  56. package/dist/server/services/people.d.ts.map +1 -0
  57. package/dist/server/services/people.js +182 -0
  58. package/dist/server/services/people.js.map +1 -0
  59. package/dist/server/services/resolve.d.ts +66 -0
  60. package/dist/server/services/resolve.d.ts.map +1 -0
  61. package/dist/server/services/resolve.js +145 -0
  62. package/dist/server/services/resolve.js.map +1 -0
  63. package/migrations/0000_init.sql +223 -0
  64. package/migrations/0001_rls.sql +141 -0
  65. package/migrations/meta/0000_snapshot.json +1715 -0
  66. package/migrations/meta/_journal.json +20 -0
  67. package/package.json +59 -0
  68. package/src/client/index.ts +62 -0
  69. package/src/contract/capabilities.ts +117 -0
  70. package/src/contract/events.ts +84 -0
  71. package/src/contract/index.ts +14 -0
  72. package/src/contract/models.ts +437 -0
  73. package/src/contract/permissions.ts +217 -0
  74. package/src/contract/router.ts +611 -0
  75. 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>