@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,1537 @@
1
+ import { KernError, requires, requiresCapability, uuidv7, workspaceScoped, } from '@kernhq/kernel';
2
+ import { implement } from '@orpc/server';
3
+ import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, sql } from 'drizzle-orm';
4
+ import { HrSettings, hrContract, hrEvents, MODULE_ID } from '../contract/index.js';
5
+ import { countWorkingDays, workingDays } from '../policy/calendar.js';
6
+ import { COUNTRY_PACKS, packDays } from './packs/index.js';
7
+ import { calendarDays, calendars, costCenters, customFieldDefs, employments, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, personDocuments, personHistory, positions, } from './schema.js';
8
+ import { inForceOn, todayIso } from './services/db.js';
9
+ import { PeopleService } from './services/people.js';
10
+ import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
11
+ const os = implement(hrContract).$context();
12
+ /**
13
+ * The router.
14
+ *
15
+ * Three middlewares, and `module.test.ts` fails if any is missing where it belongs:
16
+ * `workspaceScoped` (a real membership, HR switched on for that workspace), `requiresCapability`
17
+ * for anything behind a capability, and `requires` for the permission the call needs — in that
18
+ * order, so a workspace with HR off is refused before anything reveals which capabilities it has.
19
+ *
20
+ * Every tenant query runs inside `withWorkspace`, which sets `app.workspace_id` for the transaction.
21
+ * Outside it the RLS policy matches nothing and the query returns no rows — which is the failure
22
+ * mode to expect if a new query mysteriously finds nothing.
23
+ */
24
+ export function implement_(kernel) {
25
+ const scoped = os.use(workspaceScoped(MODULE_ID));
26
+ const cap = (id) => requiresCapability(MODULE_ID, id);
27
+ const resolve = new ResolveService();
28
+ const svc = new PeopleService(kernel);
29
+ const db = kernel.database;
30
+ const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
31
+ const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
32
+ return os.router({
33
+ // ================================================================= people
34
+ people: {
35
+ list: scoped.people.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
36
+ const where = [eq(people.workspaceId, input.workspaceId)];
37
+ if (input.q)
38
+ where.push(ilike(people.displayName, `%${input.q}%`));
39
+ if (input.status?.length)
40
+ where.push(inArray(people.status, input.status));
41
+ if (input.officeId) {
42
+ const here = await tx
43
+ .select({ personId: officeAssignments.personId })
44
+ .from(officeAssignments)
45
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
46
+ // An empty list must match nothing, not everything — `inArray(col, [])` is a false
47
+ // predicate in drizzle, but spelling it out beats relying on that.
48
+ where.push(here.length
49
+ ? inArray(people.id, here.map((r) => r.personId))
50
+ : sql `false`);
51
+ }
52
+ if (input.orgUnitId) {
53
+ const ids = await unitMemberIds(tx, input.workspaceId, input.orgUnitId, input.includeDescendants);
54
+ where.push(ids.length ? inArray(people.id, ids) : sql `false`);
55
+ }
56
+ if (input.positionId) {
57
+ const holders = await tx
58
+ .select({ personId: employments.personId })
59
+ .from(employments)
60
+ .where(and(eq(employments.workspaceId, input.workspaceId), eq(employments.positionId, input.positionId), isNull(employments.effectiveTo)));
61
+ where.push(holders.length
62
+ ? inArray(people.id, holders.map((r) => r.personId))
63
+ : sql `false`);
64
+ }
65
+ const rows = await tx
66
+ .select()
67
+ .from(people)
68
+ .where(and(...where))
69
+ .orderBy(asc(people.displayName))
70
+ .limit(input.limit);
71
+ const [total] = await tx
72
+ .select({ n: count() })
73
+ .from(people)
74
+ .where(and(...where));
75
+ return { items: rows.map(PeopleService.toPerson), nextCursor: null, total: total?.n ?? 0 };
76
+ })),
77
+ get: scoped.people.get
78
+ .use(requires('hr.person.view'))
79
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => PeopleService.toPerson(await svc.load(tx, input.workspaceId, input.personId)))),
80
+ /**
81
+ * No permission check: everybody may read their own record, and a permission nobody can lack
82
+ * is noise in the role editor. Returns null rather than 404 when the signed-in user has no HR
83
+ * record — plenty of members are not employees, and that is an answer, not a failure.
84
+ */
85
+ me: scoped.people.me.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
86
+ const userId = context.principal.userId;
87
+ if (!userId)
88
+ return null;
89
+ const row = await svc.byUserId(tx, input.workspaceId, userId);
90
+ return row ? PeopleService.toPerson(row) : null;
91
+ })),
92
+ create: scoped.people.create.use(requires('hr.person.manage')).handler(async ({ input, context }) => {
93
+ const settings = await settingsOf(input.workspaceId);
94
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
95
+ const employeeNo = input.employeeNo ?? (await svc.nextEmployeeNo(input.workspaceId, settings));
96
+ const [person] = await tx
97
+ .insert(people)
98
+ .values({
99
+ id: uuidv7(),
100
+ workspaceId: input.workspaceId,
101
+ userId: input.userId ?? null,
102
+ employeeNo,
103
+ displayName: input.displayName,
104
+ workEmail: input.workEmail ?? null,
105
+ hiredOn: input.hiredOn ?? null,
106
+ status: input.hiredOn && input.hiredOn > todayIso() ? 'onboarding' : 'active',
107
+ })
108
+ .returning();
109
+ const from = input.hiredOn ?? todayIso();
110
+ await svc.changeEmployment(tx, input.workspaceId, person.id, from, {
111
+ orgUnitId: input.orgUnitId ?? null,
112
+ positionId: input.positionId ?? null,
113
+ managerPersonId: input.managerPersonId ?? null,
114
+ employmentType: input.employmentType,
115
+ });
116
+ // Everybody lands in an office, even when the workspace has never heard the word: the
117
+ // default office is what the calendar, timezone and every policy hang off.
118
+ const office = input.officeId ?? (await resolve.defaultOffice(tx, input.workspaceId))?.id;
119
+ if (office)
120
+ await svc.assignOffice(tx, input.workspaceId, person.id, office, true, from, 'created');
121
+ await svc.record(tx, input.workspaceId, person.id, context.principal.userId ?? null, [
122
+ { field: 'created', from: null, to: input.displayName },
123
+ ]);
124
+ return person;
125
+ });
126
+ await kernel.emit(hrEvents.personCreated, { personId: row.id, workspaceId: input.workspaceId, userId: row.userId }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
127
+ await changed(input.workspaceId, 'person', row.id, 'created');
128
+ return PeopleService.toPerson(row);
129
+ }),
130
+ update: scoped.people.update.use(requires('hr.person.manage')).handler(async ({ input, context }) => {
131
+ const { workspaceId, personId, ...patch } = input;
132
+ const row = await db.withWorkspace(workspaceId, async (tx) => {
133
+ const before = await svc.load(tx, workspaceId, personId);
134
+ const set = { updatedAt: new Date() };
135
+ const history = [];
136
+ for (const [k, v] of Object.entries(patch)) {
137
+ if (v === undefined)
138
+ continue;
139
+ set[k] = v;
140
+ history.push({ field: k, from: before[k] ?? null, to: v });
141
+ }
142
+ const [updated] = await tx
143
+ .update(people)
144
+ .set(set)
145
+ .where(and(eq(people.workspaceId, workspaceId), eq(people.id, personId)))
146
+ .returning();
147
+ await svc.record(tx, workspaceId, personId, context.principal.userId ?? null, history);
148
+ return { updated: updated, fields: history.map((h) => h.field) };
149
+ });
150
+ await kernel.emit(hrEvents.personUpdated, { personId, workspaceId, fields: row.fields }, { workspaceId, actorId: context.principal.userId });
151
+ await changed(workspaceId, 'person', personId, 'updated');
152
+ return PeopleService.toPerson(row.updated);
153
+ }),
154
+ /** Ends employment and keeps the record. A terminated person is history, not a deletion. */
155
+ offboard: scoped.people.offboard
156
+ .use(requires('hr.person.manage'))
157
+ .handler(async ({ input, context }) => {
158
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
159
+ const before = await svc.load(tx, input.workspaceId, input.personId);
160
+ const [updated] = await tx
161
+ .update(people)
162
+ .set({ status: 'terminated', terminatedOn: input.on, updatedAt: new Date() })
163
+ .where(and(eq(people.workspaceId, input.workspaceId), eq(people.id, input.personId)))
164
+ .returning();
165
+ await tx
166
+ .update(employments)
167
+ .set({ effectiveTo: input.on })
168
+ .where(and(eq(employments.workspaceId, input.workspaceId), eq(employments.personId, input.personId), isNull(employments.effectiveTo)));
169
+ await tx
170
+ .update(officeAssignments)
171
+ .set({ effectiveTo: input.on })
172
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.personId, input.personId), isNull(officeAssignments.effectiveTo)));
173
+ await svc.record(tx, input.workspaceId, input.personId, context.principal.userId ?? null, [
174
+ { field: 'status', from: before.status, to: 'terminated' },
175
+ { field: 'terminatedOn', from: before.terminatedOn, to: input.on },
176
+ ]);
177
+ return { before, updated: updated };
178
+ });
179
+ await kernel.emit(hrEvents.personStatusChanged, {
180
+ personId: input.personId,
181
+ workspaceId: input.workspaceId,
182
+ from: row.before.status,
183
+ to: 'terminated',
184
+ on: input.on,
185
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
186
+ await changed(input.workspaceId, 'person', input.personId, 'updated');
187
+ return PeopleService.toPerson(row.updated);
188
+ }),
189
+ history: scoped.people.history.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
190
+ const rows = await tx
191
+ .select()
192
+ .from(personHistory)
193
+ .where(and(eq(personHistory.workspaceId, input.workspaceId), eq(personHistory.personId, input.personId)))
194
+ .orderBy(desc(personHistory.at))
195
+ .limit(input.limit);
196
+ return {
197
+ items: rows.map((r) => ({
198
+ id: r.id,
199
+ field: r.field,
200
+ from: r.from ?? null,
201
+ to: r.to ?? null,
202
+ at: r.at.toISOString(),
203
+ actorId: r.actorId,
204
+ source: r.source,
205
+ })),
206
+ nextCursor: null,
207
+ };
208
+ })),
209
+ sensitive: {
210
+ get: scoped.people.sensitive.get.use(requires('hr.person.view_sensitive')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
211
+ const [row] = await tx
212
+ .select()
213
+ .from(peopleSensitive)
214
+ .where(and(eq(peopleSensitive.workspaceId, input.workspaceId), eq(peopleSensitive.personId, input.personId)))
215
+ .limit(1);
216
+ return {
217
+ personId: input.personId,
218
+ workspaceId: input.workspaceId,
219
+ nationalId: row?.nationalIdEnc ? await kernel.secrets.decrypt(row.nationalIdEnc) : null,
220
+ birthDate: row?.birthDate ?? null,
221
+ iban: row?.ibanEnc ? await kernel.secrets.decrypt(row.ibanEnc) : null,
222
+ emergencyContact: row?.emergencyContact ?? null,
223
+ };
224
+ })),
225
+ update: scoped.people.sensitive.update
226
+ .use(requires('hr.person.manage_sensitive'))
227
+ .handler(async ({ input, context }) => {
228
+ const { workspaceId, personId } = input;
229
+ await db.withWorkspace(workspaceId, async (tx) => {
230
+ const set = { workspaceId, personId, updatedAt: new Date() };
231
+ if (input.nationalId !== undefined)
232
+ set.nationalIdEnc = input.nationalId ? await kernel.secrets.encrypt(input.nationalId) : null;
233
+ if (input.iban !== undefined)
234
+ set.ibanEnc = input.iban ? await kernel.secrets.encrypt(input.iban) : null;
235
+ if (input.birthDate !== undefined)
236
+ set.birthDate = input.birthDate;
237
+ if (input.emergencyContact !== undefined)
238
+ set.emergencyContact = input.emergencyContact;
239
+ await tx
240
+ .insert(peopleSensitive)
241
+ .values(set)
242
+ .onConflictDoUpdate({ target: peopleSensitive.personId, set });
243
+ // The values never enter the audit trail — only that they changed. An audit log that
244
+ // records a national identity number defeats the reason this table is separate.
245
+ await svc.record(tx, workspaceId, personId, context.principal.userId ?? null, [
246
+ { field: 'sensitive', from: null, to: Object.keys(input).filter((k) => k !== 'workspaceId') },
247
+ ]);
248
+ });
249
+ return {
250
+ personId,
251
+ workspaceId,
252
+ nationalId: input.nationalId ?? null,
253
+ birthDate: input.birthDate ?? null,
254
+ iban: input.iban ?? null,
255
+ emergencyContact: input.emergencyContact ?? null,
256
+ };
257
+ }),
258
+ },
259
+ },
260
+ // ================================================================= employment
261
+ employment: {
262
+ current: scoped.employment.current.use(requires('hr.employment.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
263
+ const on = input.on ?? todayIso();
264
+ const [row] = await tx
265
+ .select()
266
+ .from(employments)
267
+ .where(and(eq(employments.workspaceId, input.workspaceId), eq(employments.personId, input.personId), inForceOn(employments.effectiveFrom, employments.effectiveTo, on)))
268
+ .limit(1);
269
+ return row ? PeopleService.toEmployment(row) : null;
270
+ })),
271
+ history: scoped.employment.history.use(requires('hr.employment.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
272
+ const rows = await tx
273
+ .select()
274
+ .from(employments)
275
+ .where(and(eq(employments.workspaceId, input.workspaceId), eq(employments.personId, input.personId)))
276
+ .orderBy(desc(employments.effectiveFrom));
277
+ return rows.map(PeopleService.toEmployment);
278
+ })),
279
+ change: scoped.employment.change
280
+ .use(requires('hr.employment.manage'))
281
+ .handler(async ({ input, context }) => {
282
+ const row = await db.withWorkspace(input.workspaceId, (tx) => svc.changeEmployment(tx, input.workspaceId, input.personId, input.effectiveFrom, {
283
+ orgUnitId: input.orgUnitId ?? undefined,
284
+ positionId: input.positionId ?? undefined,
285
+ legalEntityId: input.legalEntityId ?? undefined,
286
+ costCenterId: input.costCenterId ?? undefined,
287
+ managerPersonId: input.managerPersonId ?? undefined,
288
+ employmentType: input.employmentType,
289
+ fte: input.fte === undefined ? undefined : String(input.fte),
290
+ contractHoursWeek: input.contractHoursWeek === undefined || input.contractHoursWeek === null
291
+ ? undefined
292
+ : String(input.contractHoursWeek),
293
+ reason: input.reason ?? null,
294
+ }));
295
+ await kernel.emit(hrEvents.employmentChanged, {
296
+ personId: input.personId,
297
+ workspaceId: input.workspaceId,
298
+ employmentId: row.id,
299
+ effectiveFrom: input.effectiveFrom,
300
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
301
+ await changed(input.workspaceId, 'person', input.personId, 'updated');
302
+ return PeopleService.toEmployment(row);
303
+ }),
304
+ },
305
+ // ================================================================= org
306
+ org: {
307
+ units: {
308
+ tree: scoped.org.units.tree.use(requires('hr.org.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
309
+ const where = [eq(orgUnits.workspaceId, input.workspaceId)];
310
+ if (!input.includeArchived)
311
+ where.push(isNull(orgUnits.archivedAt));
312
+ const rows = await tx
313
+ .select()
314
+ .from(orgUnits)
315
+ .where(and(...where))
316
+ .orderBy(asc(orgUnits.path));
317
+ // Headcount per unit in one grouped query rather than one per node: an org chart with
318
+ // two hundred departments would otherwise be two hundred round trips to draw.
319
+ const counts = await tx
320
+ .select({ unitId: employments.orgUnitId, n: count() })
321
+ .from(employments)
322
+ .where(and(eq(employments.workspaceId, input.workspaceId), isNull(employments.effectiveTo)))
323
+ .groupBy(employments.orgUnitId);
324
+ const byUnit = new Map(counts.map((c) => [c.unitId, c.n]));
325
+ return rows.map((r) => ({
326
+ ...r,
327
+ archivedAt: r.archivedAt?.toISOString() ?? null,
328
+ headcount: byUnit.get(r.id) ?? 0,
329
+ }));
330
+ })),
331
+ create: scoped.org.units.create.use(requires('hr.org.manage')).handler(async ({ input }) => {
332
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
333
+ const id = uuidv7();
334
+ const path = await childPath(tx, input.workspaceId, input.parentId ?? null, id);
335
+ const [created] = await tx
336
+ .insert(orgUnits)
337
+ .values({
338
+ id,
339
+ workspaceId: input.workspaceId,
340
+ parentId: input.parentId ?? null,
341
+ path,
342
+ name: input.name,
343
+ code: input.code ?? null,
344
+ headPersonId: input.headPersonId ?? null,
345
+ })
346
+ .returning();
347
+ return created;
348
+ });
349
+ await changed(input.workspaceId, 'org_unit', row.id, 'created');
350
+ return { ...row, archivedAt: null };
351
+ }),
352
+ update: scoped.org.units.update.use(requires('hr.org.manage')).handler(async ({ input }) => {
353
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
354
+ const set = { updatedAt: new Date() };
355
+ if (input.name !== undefined)
356
+ set.name = input.name;
357
+ if (input.code !== undefined)
358
+ set.code = input.code;
359
+ if (input.headPersonId !== undefined)
360
+ set.headPersonId = input.headPersonId;
361
+ const [updated] = await tx
362
+ .update(orgUnits)
363
+ .set(set)
364
+ .where(and(eq(orgUnits.workspaceId, input.workspaceId), eq(orgUnits.id, input.unitId)))
365
+ .returning();
366
+ if (!updated)
367
+ throw KernError.notFound('Department');
368
+ return updated;
369
+ });
370
+ await changed(input.workspaceId, 'org_unit', row.id, 'updated');
371
+ return { ...row, archivedAt: row.archivedAt?.toISOString() ?? null };
372
+ }),
373
+ /**
374
+ * Reparent a unit and rewrite the ltree path of everything beneath it.
375
+ *
376
+ * One `UPDATE` over the subtree rather than a walk, and it refuses to move a unit under its
377
+ * own descendant — which would detach that whole branch from the root and is the one way an
378
+ * ltree hierarchy can be corrupted beyond repair by an ordinary drag.
379
+ */
380
+ move: scoped.org.units.move.use(requires('hr.org.manage')).handler(async ({ input }) => {
381
+ const rows = await db.withWorkspace(input.workspaceId, async (tx) => {
382
+ const [unit] = await tx
383
+ .select()
384
+ .from(orgUnits)
385
+ .where(and(eq(orgUnits.workspaceId, input.workspaceId), eq(orgUnits.id, input.unitId)))
386
+ .limit(1);
387
+ if (!unit)
388
+ throw KernError.notFound('Department');
389
+ const newParentPath = await parentPath(tx, input.workspaceId, input.parentId);
390
+ if (input.parentId) {
391
+ const [target] = await tx
392
+ .select({ path: orgUnits.path })
393
+ .from(orgUnits)
394
+ .where(and(eq(orgUnits.workspaceId, input.workspaceId), eq(orgUnits.id, input.parentId)))
395
+ .limit(1);
396
+ if (!target)
397
+ throw KernError.notFound('Department');
398
+ if (target.path === unit.path || target.path.startsWith(`${unit.path}.`))
399
+ throw KernError.badRequest('A department cannot be moved underneath itself.');
400
+ }
401
+ const label = unit.path.split('.').pop();
402
+ const nextPath = newParentPath ? `${newParentPath}.${label}` : label;
403
+ await tx.execute(sql `
404
+ update ${orgUnits}
405
+ set path = ${nextPath}::ltree || subpath(path, nlevel(${unit.path}::ltree)),
406
+ updated_at = now()
407
+ where workspace_id = ${input.workspaceId}
408
+ and path <@ ${unit.path}::ltree
409
+ `);
410
+ await tx.update(orgUnits).set({ parentId: input.parentId }).where(eq(orgUnits.id, input.unitId));
411
+ return tx
412
+ .select()
413
+ .from(orgUnits)
414
+ .where(and(eq(orgUnits.workspaceId, input.workspaceId), sql `path <@ ${nextPath}::ltree`))
415
+ .orderBy(asc(orgUnits.path));
416
+ });
417
+ await changed(input.workspaceId, 'org_unit', input.unitId, 'updated');
418
+ return rows.map((r) => ({ ...r, archivedAt: r.archivedAt?.toISOString() ?? null }));
419
+ }),
420
+ archive: scoped.org.units.archive.use(requires('hr.org.manage')).handler(async ({ input }) => {
421
+ await db.withWorkspace(input.workspaceId, async (tx) => {
422
+ const [held] = await tx
423
+ .select({ n: count() })
424
+ .from(employments)
425
+ .where(and(eq(employments.workspaceId, input.workspaceId), eq(employments.orgUnitId, input.unitId), isNull(employments.effectiveTo)));
426
+ if ((held?.n ?? 0) > 0)
427
+ throw KernError.conflict(`${held?.n} people still report into this department. Move them first.`);
428
+ await tx
429
+ .update(orgUnits)
430
+ .set({ archivedAt: new Date() })
431
+ .where(and(eq(orgUnits.workspaceId, input.workspaceId), eq(orgUnits.id, input.unitId)));
432
+ });
433
+ await changed(input.workspaceId, 'org_unit', input.unitId, 'deleted');
434
+ return { ok: true };
435
+ }),
436
+ },
437
+ positions: {
438
+ list: scoped.org.positions.list.use(requires('hr.org.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
439
+ const where = [eq(positions.workspaceId, input.workspaceId)];
440
+ if (!input.includeArchived)
441
+ where.push(isNull(positions.archivedAt));
442
+ const rows = await tx
443
+ .select()
444
+ .from(positions)
445
+ .where(and(...where))
446
+ .orderBy(asc(positions.title));
447
+ return rows.map((r) => ({ ...r, archivedAt: r.archivedAt?.toISOString() ?? null }));
448
+ })),
449
+ create: scoped.org.positions.create.use(requires('hr.org.manage')).handler(async ({ input }) => {
450
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
451
+ const [created] = await tx
452
+ .insert(positions)
453
+ .values({
454
+ id: uuidv7(),
455
+ workspaceId: input.workspaceId,
456
+ title: input.title,
457
+ code: input.code ?? null,
458
+ jobFamily: input.jobFamily ?? null,
459
+ level: input.level ?? null,
460
+ })
461
+ .returning();
462
+ return created;
463
+ });
464
+ await changed(input.workspaceId, 'position', row.id, 'created');
465
+ return { ...row, archivedAt: null };
466
+ }),
467
+ update: scoped.org.positions.update.use(requires('hr.org.manage')).handler(async ({ input }) => {
468
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
469
+ const set = {};
470
+ if (input.title !== undefined)
471
+ set.title = input.title;
472
+ if (input.code !== undefined)
473
+ set.code = input.code;
474
+ if (input.jobFamily !== undefined)
475
+ set.jobFamily = input.jobFamily;
476
+ if (input.level !== undefined)
477
+ set.level = input.level;
478
+ const [updated] = await tx
479
+ .update(positions)
480
+ .set(set)
481
+ .where(and(eq(positions.workspaceId, input.workspaceId), eq(positions.id, input.positionId)))
482
+ .returning();
483
+ if (!updated)
484
+ throw KernError.notFound('Position');
485
+ return updated;
486
+ });
487
+ await changed(input.workspaceId, 'position', row.id, 'updated');
488
+ return { ...row, archivedAt: row.archivedAt?.toISOString() ?? null };
489
+ }),
490
+ archive: scoped.org.positions.archive.use(requires('hr.org.manage')).handler(async ({ input }) => {
491
+ await db.withWorkspace(input.workspaceId, (tx) => tx
492
+ .update(positions)
493
+ .set({ archivedAt: new Date() })
494
+ .where(and(eq(positions.workspaceId, input.workspaceId), eq(positions.id, input.positionId))));
495
+ await changed(input.workspaceId, 'position', input.positionId, 'deleted');
496
+ return { ok: true };
497
+ }),
498
+ },
499
+ },
500
+ // ================================================================= offices
501
+ offices: {
502
+ list: scoped.offices.list
503
+ .use(cap('offices'))
504
+ .use(requires('hr.office.view'))
505
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
506
+ const where = [eq(offices.workspaceId, input.workspaceId)];
507
+ if (!input.includeArchived)
508
+ where.push(isNull(offices.archivedAt));
509
+ const rows = await tx
510
+ .select()
511
+ .from(offices)
512
+ .where(and(...where))
513
+ .orderBy(desc(offices.isDefault), asc(offices.name));
514
+ const counts = await tx
515
+ .select({ officeId: officeAssignments.officeId, n: count() })
516
+ .from(officeAssignments)
517
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.isPrimary, true), isNull(officeAssignments.effectiveTo)))
518
+ .groupBy(officeAssignments.officeId);
519
+ const byOffice = new Map(counts.map((c) => [c.officeId, c.n]));
520
+ return rows.map((r) => ({ ...toOffice(r), headcount: byOffice.get(r.id) ?? 0 }));
521
+ })),
522
+ get: scoped.offices.get
523
+ .use(cap('offices'))
524
+ .use(requires('hr.office.view'))
525
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => toOffice(await loadOffice(tx, input)))),
526
+ create: scoped.offices.create
527
+ .use(cap('offices'))
528
+ .use(requires('hr.office.manage'))
529
+ .handler(async ({ input, context }) => {
530
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
531
+ let calendarId = null;
532
+ if (input.seedCalendarFromPack) {
533
+ // The office's own calendar *extends* the country pack rather than copying it, so a
534
+ // pack refresh reaches this office without reconciling a copy — and days HR add here
535
+ // stay `custom` and survive that refresh untouched.
536
+ const base = await packCalendar(tx, input.workspaceId, input.country);
537
+ const [own] = await tx
538
+ .insert(calendars)
539
+ .values({
540
+ id: uuidv7(),
541
+ workspaceId: input.workspaceId,
542
+ name: input.name,
543
+ extendsId: base?.id ?? null,
544
+ country: input.country,
545
+ region: input.region ?? null,
546
+ workingWeek: base?.workingWeek ?? DEFAULT_WORKING_WEEK,
547
+ source: 'custom',
548
+ })
549
+ .returning();
550
+ calendarId = own.id;
551
+ }
552
+ const [created] = await tx
553
+ .insert(offices)
554
+ .values({
555
+ id: uuidv7(),
556
+ workspaceId: input.workspaceId,
557
+ name: input.name,
558
+ kind: input.kind,
559
+ code: input.code ?? null,
560
+ parentOfficeId: input.parentOfficeId ?? null,
561
+ legalEntityId: input.legalEntityId ?? null,
562
+ country: input.country,
563
+ region: input.region ?? null,
564
+ city: input.city ?? null,
565
+ timezone: input.timezone,
566
+ calendarId,
567
+ isDefault: false,
568
+ })
569
+ .returning();
570
+ return created;
571
+ });
572
+ await kernel.emit(hrEvents.officeCreated, { officeId: row.id, workspaceId: input.workspaceId, country: row.country }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
573
+ await changed(input.workspaceId, 'office', row.id, 'created');
574
+ return toOffice(row);
575
+ }),
576
+ update: scoped.offices.update
577
+ .use(cap('offices'))
578
+ .use(requires('hr.office.manage'))
579
+ .handler(async ({ input }) => {
580
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
581
+ const { workspaceId, officeId, ...patch } = input;
582
+ const set = { updatedAt: new Date() };
583
+ for (const [k, v] of Object.entries(patch))
584
+ if (v !== undefined)
585
+ set[k] = v;
586
+ const [updated] = await tx
587
+ .update(offices)
588
+ .set(set)
589
+ .where(and(eq(offices.workspaceId, workspaceId), eq(offices.id, officeId)))
590
+ .returning();
591
+ if (!updated)
592
+ throw KernError.notFound('Office');
593
+ return updated;
594
+ });
595
+ await changed(input.workspaceId, 'office', row.id, 'updated');
596
+ return toOffice(row);
597
+ }),
598
+ archive: scoped.offices.archive
599
+ .use(cap('offices'))
600
+ .use(requires('hr.office.manage'))
601
+ .handler(async ({ input }) => {
602
+ await db.withWorkspace(input.workspaceId, async (tx) => {
603
+ const office = await loadOffice(tx, input);
604
+ // The default office is where everyone without an assignment lands and where the
605
+ // resolution ladder bottoms out. Archiving it would leave people with no calendar and no
606
+ // timezone, so it has to be handed over first.
607
+ if (office.isDefault)
608
+ throw KernError.conflict('This is the default office. Make another office the default before archiving it.');
609
+ const [held] = await tx
610
+ .select({ n: count() })
611
+ .from(officeAssignments)
612
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
613
+ if ((held?.n ?? 0) > 0)
614
+ throw KernError.conflict(`${held?.n} people still work here. Move them first.`);
615
+ await tx
616
+ .update(offices)
617
+ .set({ archivedAt: new Date() })
618
+ .where(and(eq(offices.workspaceId, input.workspaceId), eq(offices.id, input.officeId)));
619
+ });
620
+ await changed(input.workspaceId, 'office', input.officeId, 'deleted');
621
+ return { ok: true };
622
+ }),
623
+ setDefault: scoped.offices.setDefault
624
+ .use(cap('offices'))
625
+ .use(requires('hr.office.manage'))
626
+ .handler(async ({ input }) => {
627
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
628
+ // Clear first, then set. A partial unique index enforces one default per workspace, so
629
+ // the other order fails on the constraint rather than doing the obvious thing.
630
+ await tx
631
+ .update(offices)
632
+ .set({ isDefault: false })
633
+ .where(and(eq(offices.workspaceId, input.workspaceId), eq(offices.isDefault, true)));
634
+ const [updated] = await tx
635
+ .update(offices)
636
+ .set({ isDefault: true, updatedAt: new Date() })
637
+ .where(and(eq(offices.workspaceId, input.workspaceId), eq(offices.id, input.officeId)))
638
+ .returning();
639
+ if (!updated)
640
+ throw KernError.notFound('Office');
641
+ return updated;
642
+ });
643
+ await changed(input.workspaceId, 'office', row.id, 'updated');
644
+ return toOffice(row);
645
+ }),
646
+ people: scoped.offices.people
647
+ .use(cap('offices'))
648
+ .use(requires('hr.office.view'))
649
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
650
+ const where = [
651
+ eq(officeAssignments.workspaceId, input.workspaceId),
652
+ eq(officeAssignments.officeId, input.officeId),
653
+ isNull(officeAssignments.effectiveTo),
654
+ ];
655
+ if (input.primaryOnly)
656
+ where.push(eq(officeAssignments.isPrimary, true));
657
+ const assignments = await tx
658
+ .select()
659
+ .from(officeAssignments)
660
+ .where(and(...where))
661
+ .limit(input.limit);
662
+ if (!assignments.length)
663
+ return { items: [], nextCursor: null, total: 0 };
664
+ const rows = await tx
665
+ .select()
666
+ .from(people)
667
+ .where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, assignments.map((a) => a.personId))))
668
+ .orderBy(asc(people.displayName));
669
+ const primaryHere = new Set(assignments.filter((a) => a.isPrimary).map((a) => a.personId));
670
+ return {
671
+ items: rows.map((r) => ({
672
+ ...PeopleService.toPerson(r),
673
+ isPrimaryHere: primaryHere.has(r.id),
674
+ })),
675
+ nextCursor: null,
676
+ total: rows.length,
677
+ };
678
+ })),
679
+ assign: scoped.offices.assign
680
+ .use(cap('offices'))
681
+ .use(requires('hr.office.assign'))
682
+ .handler(async ({ input, context }) => {
683
+ const rows = await db.withWorkspace(input.workspaceId, async (tx) => {
684
+ await loadOffice(tx, input);
685
+ return svc.assignOffice(tx, input.workspaceId, input.personId, input.officeId, input.isPrimary, input.effectiveFrom, input.reason ?? null);
686
+ });
687
+ await kernel.emit(hrEvents.officeAssignmentChanged, {
688
+ personId: input.personId,
689
+ workspaceId: input.workspaceId,
690
+ officeId: input.officeId,
691
+ isPrimary: input.isPrimary,
692
+ effectiveFrom: input.effectiveFrom,
693
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
694
+ await changed(input.workspaceId, 'person', input.personId, 'updated');
695
+ return rows.map(PeopleService.toAssignment);
696
+ }),
697
+ unassign: scoped.offices.unassign
698
+ .use(cap('offices'))
699
+ .use(requires('hr.office.assign'))
700
+ .handler(async ({ input, context }) => {
701
+ await db.withWorkspace(input.workspaceId, async (tx) => {
702
+ const [row] = await tx
703
+ .select()
704
+ .from(officeAssignments)
705
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), eq(officeAssignments.personId, input.personId), isNull(officeAssignments.effectiveTo)))
706
+ .limit(1);
707
+ if (!row)
708
+ throw KernError.notFound('Office assignment');
709
+ // Removing somebody's *primary* office leaves them with no calendar, no timezone and no
710
+ // policy — a person in that state is what the resolution ladder cannot answer for. Make
711
+ // another office primary first.
712
+ if (row.isPrimary)
713
+ throw KernError.conflict('This is their primary office. Assign another office as primary first.');
714
+ await tx
715
+ .update(officeAssignments)
716
+ .set({ effectiveTo: input.effectiveTo })
717
+ .where(eq(officeAssignments.id, row.id));
718
+ });
719
+ await kernel.emit(hrEvents.officeAssignmentChanged, {
720
+ personId: input.personId,
721
+ workspaceId: input.workspaceId,
722
+ officeId: input.officeId,
723
+ isPrimary: false,
724
+ effectiveFrom: input.effectiveTo,
725
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
726
+ await changed(input.workspaceId, 'person', input.personId, 'updated');
727
+ return { ok: true };
728
+ }),
729
+ /**
730
+ * Not behind the `offices` capability, deliberately.
731
+ *
732
+ * A workspace with one office still has a ladder, and this is the first thing anybody reaches
733
+ * for when a holiday or a timezone looks wrong. Gating it on the capability would mean the
734
+ * support answer is only available to workspaces that already understand the model.
735
+ */
736
+ resolveFor: scoped.offices.resolveFor
737
+ .use(requires('hr.person.view'))
738
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, (tx) => resolve.forPerson(tx, input.workspaceId, input.personId, input.on))),
739
+ },
740
+ // ================================================================= legal entities
741
+ entities: {
742
+ list: scoped.entities.list
743
+ .use(cap('legal_entities'))
744
+ .use(requires('hr.entity.view'))
745
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
746
+ const where = [eq(legalEntities.workspaceId, input.workspaceId)];
747
+ if (!input.includeArchived)
748
+ where.push(isNull(legalEntities.archivedAt));
749
+ const rows = await tx
750
+ .select()
751
+ .from(legalEntities)
752
+ .where(and(...where))
753
+ .orderBy(asc(legalEntities.name));
754
+ return rows.map(toEntity);
755
+ })),
756
+ get: scoped.entities.get
757
+ .use(cap('legal_entities'))
758
+ .use(requires('hr.entity.view'))
759
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
760
+ const [row] = await tx
761
+ .select()
762
+ .from(legalEntities)
763
+ .where(and(eq(legalEntities.workspaceId, input.workspaceId), eq(legalEntities.id, input.entityId)))
764
+ .limit(1);
765
+ if (!row)
766
+ throw KernError.notFound('Legal entity');
767
+ return toEntity(row);
768
+ })),
769
+ create: scoped.entities.create
770
+ .use(cap('legal_entities'))
771
+ .use(requires('hr.entity.manage'))
772
+ .handler(async ({ input }) => {
773
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
774
+ const [created] = await tx
775
+ .insert(legalEntities)
776
+ .values({
777
+ id: uuidv7(),
778
+ workspaceId: input.workspaceId,
779
+ name: input.name,
780
+ country: input.country,
781
+ registrationNo: input.registrationNo ?? null,
782
+ taxNo: input.taxNo ?? null,
783
+ currency: input.currency ?? null,
784
+ })
785
+ .returning();
786
+ return created;
787
+ });
788
+ await changed(input.workspaceId, 'legal_entity', row.id, 'created');
789
+ return toEntity(row);
790
+ }),
791
+ update: scoped.entities.update
792
+ .use(cap('legal_entities'))
793
+ .use(requires('hr.entity.manage'))
794
+ .handler(async ({ input }) => {
795
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
796
+ const { workspaceId, entityId, ...patch } = input;
797
+ const set = { updatedAt: new Date() };
798
+ for (const [k, v] of Object.entries(patch))
799
+ if (v !== undefined)
800
+ set[k] = v;
801
+ const [updated] = await tx
802
+ .update(legalEntities)
803
+ .set(set)
804
+ .where(and(eq(legalEntities.workspaceId, workspaceId), eq(legalEntities.id, entityId)))
805
+ .returning();
806
+ if (!updated)
807
+ throw KernError.notFound('Legal entity');
808
+ return updated;
809
+ });
810
+ await changed(input.workspaceId, 'legal_entity', row.id, 'updated');
811
+ return toEntity(row);
812
+ }),
813
+ archive: scoped.entities.archive
814
+ .use(cap('legal_entities'))
815
+ .use(requires('hr.entity.manage'))
816
+ .handler(async ({ input }) => {
817
+ await db.withWorkspace(input.workspaceId, (tx) => tx
818
+ .update(legalEntities)
819
+ .set({ archivedAt: new Date() })
820
+ .where(and(eq(legalEntities.workspaceId, input.workspaceId), eq(legalEntities.id, input.entityId))));
821
+ await changed(input.workspaceId, 'legal_entity', input.entityId, 'deleted');
822
+ return { ok: true };
823
+ }),
824
+ costCenters: {
825
+ list: scoped.entities.costCenters.list
826
+ .use(cap('legal_entities'))
827
+ .use(requires('hr.entity.view'))
828
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
829
+ const where = [eq(costCenters.workspaceId, input.workspaceId)];
830
+ if (!input.includeArchived)
831
+ where.push(isNull(costCenters.archivedAt));
832
+ const rows = await tx
833
+ .select()
834
+ .from(costCenters)
835
+ .where(and(...where))
836
+ .orderBy(asc(costCenters.code));
837
+ return rows.map((r) => ({ ...r, archivedAt: r.archivedAt?.toISOString() ?? null }));
838
+ })),
839
+ create: scoped.entities.costCenters.create
840
+ .use(cap('legal_entities'))
841
+ .use(requires('hr.entity.manage'))
842
+ .handler(async ({ input }) => {
843
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
844
+ const [created] = await tx
845
+ .insert(costCenters)
846
+ .values({
847
+ id: uuidv7(),
848
+ workspaceId: input.workspaceId,
849
+ code: input.code,
850
+ name: input.name,
851
+ officeId: input.officeId ?? null,
852
+ orgUnitId: input.orgUnitId ?? null,
853
+ legalEntityId: input.legalEntityId ?? null,
854
+ })
855
+ .returning();
856
+ return created;
857
+ });
858
+ await changed(input.workspaceId, 'cost_center', row.id, 'created');
859
+ return { ...row, archivedAt: null };
860
+ }),
861
+ archive: scoped.entities.costCenters.archive
862
+ .use(cap('legal_entities'))
863
+ .use(requires('hr.entity.manage'))
864
+ .handler(async ({ input }) => {
865
+ await db.withWorkspace(input.workspaceId, (tx) => tx
866
+ .update(costCenters)
867
+ .set({ archivedAt: new Date() })
868
+ .where(and(eq(costCenters.workspaceId, input.workspaceId), eq(costCenters.id, input.costCenterId))));
869
+ await changed(input.workspaceId, 'cost_center', input.costCenterId, 'deleted');
870
+ return { ok: true };
871
+ }),
872
+ },
873
+ },
874
+ // ================================================================= calendars
875
+ calendars: {
876
+ list: scoped.calendars.list
877
+ .use(cap('calendars'))
878
+ .use(requires('hr.calendar.view'))
879
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
880
+ const where = [eq(calendars.workspaceId, input.workspaceId)];
881
+ if (!input.includeArchived)
882
+ where.push(isNull(calendars.archivedAt));
883
+ const rows = await tx
884
+ .select()
885
+ .from(calendars)
886
+ .where(and(...where))
887
+ .orderBy(asc(calendars.name));
888
+ const used = await tx
889
+ .select({ calendarId: offices.calendarId, officeId: offices.id })
890
+ .from(offices)
891
+ .where(eq(offices.workspaceId, input.workspaceId));
892
+ return rows.map((r) => ({
893
+ ...toCalendar(r),
894
+ officeIds: used.filter((u) => u.calendarId === r.id).map((u) => u.officeId),
895
+ }));
896
+ })),
897
+ get: scoped.calendars.get
898
+ .use(cap('calendars'))
899
+ .use(requires('hr.calendar.view'))
900
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => toCalendar(await loadCalendar(tx, input.workspaceId, input.calendarId)))),
901
+ create: scoped.calendars.create
902
+ .use(cap('calendars'))
903
+ .use(requires('hr.calendar.manage'))
904
+ .handler(async ({ input }) => {
905
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
906
+ if (input.extendsId)
907
+ await assertChainDepth(tx, input.workspaceId, input.extendsId);
908
+ const [created] = await tx
909
+ .insert(calendars)
910
+ .values({
911
+ id: uuidv7(),
912
+ workspaceId: input.workspaceId,
913
+ name: input.name,
914
+ extendsId: input.extendsId ?? null,
915
+ country: input.country ?? null,
916
+ region: input.region ?? null,
917
+ workingWeek: input.workingWeek ?? DEFAULT_WORKING_WEEK,
918
+ source: 'custom',
919
+ })
920
+ .returning();
921
+ return created;
922
+ });
923
+ await changed(input.workspaceId, 'calendar', row.id, 'created');
924
+ return toCalendar(row);
925
+ }),
926
+ update: scoped.calendars.update
927
+ .use(cap('calendars'))
928
+ .use(requires('hr.calendar.manage'))
929
+ .handler(async ({ input, context }) => {
930
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
931
+ if (input.extendsId) {
932
+ if (input.extendsId === input.calendarId)
933
+ throw KernError.badRequest('A calendar cannot extend itself.');
934
+ await assertChainDepth(tx, input.workspaceId, input.extendsId, input.calendarId);
935
+ }
936
+ const set = { updatedAt: new Date() };
937
+ if (input.name !== undefined)
938
+ set.name = input.name;
939
+ if (input.workingWeek !== undefined)
940
+ set.workingWeek = input.workingWeek;
941
+ if (input.extendsId !== undefined)
942
+ set.extendsId = input.extendsId;
943
+ const [updated] = await tx
944
+ .update(calendars)
945
+ .set(set)
946
+ .where(and(eq(calendars.workspaceId, input.workspaceId), eq(calendars.id, input.calendarId)))
947
+ .returning();
948
+ if (!updated)
949
+ throw KernError.notFound('Calendar');
950
+ return updated;
951
+ });
952
+ await emitCalendarChanged(input.workspaceId, input.calendarId, null, null, context.principal.userId);
953
+ return toCalendar(row);
954
+ }),
955
+ archive: scoped.calendars.archive
956
+ .use(cap('calendars'))
957
+ .use(requires('hr.calendar.manage'))
958
+ .handler(async ({ input }) => {
959
+ await db.withWorkspace(input.workspaceId, async (tx) => {
960
+ const [used] = await tx
961
+ .select({ n: count() })
962
+ .from(offices)
963
+ .where(and(eq(offices.workspaceId, input.workspaceId), eq(offices.calendarId, input.calendarId)));
964
+ if ((used?.n ?? 0) > 0)
965
+ throw KernError.conflict(`${used?.n} offices use this calendar. Point them at another one first.`);
966
+ await tx
967
+ .update(calendars)
968
+ .set({ archivedAt: new Date() })
969
+ .where(and(eq(calendars.workspaceId, input.workspaceId), eq(calendars.id, input.calendarId)));
970
+ });
971
+ await changed(input.workspaceId, 'calendar', input.calendarId, 'deleted');
972
+ return { ok: true };
973
+ }),
974
+ days: {
975
+ list: scoped.calendars.days.list
976
+ .use(cap('calendars'))
977
+ .use(requires('hr.calendar.view'))
978
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, (tx) => composedDays(tx, input.workspaceId, input.calendarId, input.from, input.to))),
979
+ add: scoped.calendars.days.add
980
+ .use(cap('calendars'))
981
+ .use(requires('hr.calendar.manage'))
982
+ .handler(async ({ input, context }) => {
983
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
984
+ await loadCalendar(tx, input.workspaceId, input.calendarId);
985
+ const [created] = await tx
986
+ .insert(calendarDays)
987
+ .values({
988
+ id: uuidv7(),
989
+ workspaceId: input.workspaceId,
990
+ calendarId: input.calendarId,
991
+ date: input.date,
992
+ kind: input.kind,
993
+ name: input.name,
994
+ workingFraction: String(input.workingFraction),
995
+ // Always `custom`. A day HR adds is theirs, and a pack upgrade must never
996
+ // overwrite or remove it — that is the whole point of tracking source per day.
997
+ source: 'custom',
998
+ paid: input.paid,
999
+ note: input.note ?? null,
1000
+ })
1001
+ .onConflictDoUpdate({
1002
+ target: [calendarDays.calendarId, calendarDays.date, calendarDays.kind],
1003
+ set: {
1004
+ name: input.name,
1005
+ workingFraction: String(input.workingFraction),
1006
+ paid: input.paid,
1007
+ note: input.note ?? null,
1008
+ source: 'custom',
1009
+ },
1010
+ })
1011
+ .returning();
1012
+ return created;
1013
+ });
1014
+ await emitCalendarChanged(input.workspaceId, input.calendarId, input.date, input.date, context.principal.userId);
1015
+ return toResolvedDay(row, input.calendarId, '', false);
1016
+ }),
1017
+ update: scoped.calendars.days.update
1018
+ .use(cap('calendars'))
1019
+ .use(requires('hr.calendar.manage'))
1020
+ .handler(async ({ input, context }) => {
1021
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1022
+ const set = {};
1023
+ if (input.name !== undefined)
1024
+ set.name = input.name;
1025
+ if (input.kind !== undefined)
1026
+ set.kind = input.kind;
1027
+ if (input.workingFraction !== undefined)
1028
+ set.workingFraction = String(input.workingFraction);
1029
+ if (input.paid !== undefined)
1030
+ set.paid = input.paid;
1031
+ if (input.note !== undefined)
1032
+ set.note = input.note;
1033
+ // Editing a pack day makes it the workspace's own. Leaving it `pack` would mean the
1034
+ // next upgrade silently reverted the edit.
1035
+ set.source = 'custom';
1036
+ const [updated] = await tx
1037
+ .update(calendarDays)
1038
+ .set(set)
1039
+ .where(and(eq(calendarDays.workspaceId, input.workspaceId), eq(calendarDays.id, input.dayId)))
1040
+ .returning();
1041
+ if (!updated)
1042
+ throw KernError.notFound('Calendar day');
1043
+ return updated;
1044
+ });
1045
+ await emitCalendarChanged(input.workspaceId, input.calendarId, row.date, row.date, context.principal.userId);
1046
+ return toResolvedDay(row, input.calendarId, '', false);
1047
+ }),
1048
+ /**
1049
+ * A `custom` day is deleted. A `pack` day cannot be — it belongs to the pack and the next
1050
+ * upgrade would bring it straight back — so this writes a suppressing `working_override`
1051
+ * over it and says so, rather than appearing to work and silently undoing itself in January.
1052
+ */
1053
+ remove: scoped.calendars.days.remove
1054
+ .use(cap('calendars'))
1055
+ .use(requires('hr.calendar.manage'))
1056
+ .handler(async ({ input, context }) => {
1057
+ const result = await db.withWorkspace(input.workspaceId, async (tx) => {
1058
+ const [row] = await tx
1059
+ .select()
1060
+ .from(calendarDays)
1061
+ .where(and(eq(calendarDays.workspaceId, input.workspaceId), eq(calendarDays.id, input.dayId)))
1062
+ .limit(1);
1063
+ if (!row)
1064
+ throw KernError.notFound('Calendar day');
1065
+ if (row.source === 'custom' && row.calendarId === input.calendarId) {
1066
+ await tx.delete(calendarDays).where(eq(calendarDays.id, row.id));
1067
+ return { date: row.date, suppressed: false };
1068
+ }
1069
+ await tx
1070
+ .insert(calendarDays)
1071
+ .values({
1072
+ id: uuidv7(),
1073
+ workspaceId: input.workspaceId,
1074
+ calendarId: input.calendarId,
1075
+ date: row.date,
1076
+ kind: 'working_override',
1077
+ name: row.name,
1078
+ workingFraction: '1',
1079
+ source: 'custom',
1080
+ paid: false,
1081
+ note: 'Worked despite the calendar it extends',
1082
+ })
1083
+ .onConflictDoNothing();
1084
+ return { date: row.date, suppressed: true };
1085
+ });
1086
+ await emitCalendarChanged(input.workspaceId, input.calendarId, result.date, result.date, context.principal.userId);
1087
+ return { ok: true, suppressed: result.suppressed };
1088
+ }),
1089
+ },
1090
+ pack: {
1091
+ preview: scoped.calendars.pack.preview
1092
+ .use(cap('calendars'))
1093
+ .use(requires('hr.calendar.manage'))
1094
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, (tx) => diffPack(tx, input.workspaceId, input.calendarId, input.packKey, input.year))),
1095
+ apply: scoped.calendars.pack.apply
1096
+ .use(cap('calendars'))
1097
+ .use(requires('hr.calendar.manage'))
1098
+ .handler(async ({ input, context }) => {
1099
+ const result = await db.withWorkspace(input.workspaceId, async (tx) => {
1100
+ const diff = await diffPack(tx, input.workspaceId, input.calendarId, input.packKey, input.year);
1101
+ const from = `${input.year}-01-01`;
1102
+ const to = `${input.year}-12-31`;
1103
+ // Only `pack` rows are touched. Everything HR added stays exactly as it is — which is
1104
+ // the promise the preview made, and the one thing this operation must never break.
1105
+ await tx
1106
+ .delete(calendarDays)
1107
+ .where(and(eq(calendarDays.workspaceId, input.workspaceId), eq(calendarDays.calendarId, input.calendarId), eq(calendarDays.source, 'pack'), gte(calendarDays.date, from), lte(calendarDays.date, to)));
1108
+ const pack = packDays(input.packKey, input.year);
1109
+ if (pack.length)
1110
+ await tx
1111
+ .insert(calendarDays)
1112
+ .values(pack.map((d) => ({
1113
+ id: uuidv7(),
1114
+ workspaceId: input.workspaceId,
1115
+ calendarId: input.calendarId,
1116
+ date: d.date,
1117
+ kind: d.kind,
1118
+ name: d.name,
1119
+ workingFraction: String(d.workingFraction),
1120
+ source: 'pack',
1121
+ paid: true,
1122
+ })))
1123
+ .onConflictDoNothing();
1124
+ await tx
1125
+ .update(calendars)
1126
+ .set({ packKey: input.packKey, packVersion: String(input.year), updatedAt: new Date() })
1127
+ .where(eq(calendars.id, input.calendarId));
1128
+ return diff;
1129
+ });
1130
+ await emitCalendarChanged(input.workspaceId, input.calendarId, `${input.year}-01-01`, `${input.year}-12-31`, context.principal.userId);
1131
+ return {
1132
+ ok: true,
1133
+ added: result.added.length,
1134
+ changed: result.changed.length,
1135
+ removed: result.removed.length,
1136
+ };
1137
+ }),
1138
+ },
1139
+ workingDays: scoped.calendars.workingDays
1140
+ .use(cap('calendars'))
1141
+ .use(requires('hr.calendar.view'))
1142
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1143
+ let calendarId = input.calendarId ?? null;
1144
+ let week = DEFAULT_WORKING_WEEK;
1145
+ if (input.personId) {
1146
+ const r = await resolve.forPerson(tx, input.workspaceId, input.personId, input.from);
1147
+ calendarId = r.calendarId;
1148
+ week = r.workingWeek;
1149
+ }
1150
+ else if (calendarId) {
1151
+ week = (await loadCalendar(tx, input.workspaceId, calendarId))
1152
+ .workingWeek;
1153
+ }
1154
+ const days = calendarId
1155
+ ? await composedDays(tx, input.workspaceId, calendarId, input.from, input.to)
1156
+ : [];
1157
+ const results = workingDays(input.from, input.to, week, days.map((d) => ({ date: d.date, name: d.name, workingFraction: d.workingFraction })));
1158
+ return { days: countWorkingDays(results), breakdown: results };
1159
+ })),
1160
+ },
1161
+ // ================================================================= documents
1162
+ documents: {
1163
+ list: scoped.documents.list
1164
+ .use(cap('documents'))
1165
+ .use(requires('hr.document.view'))
1166
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1167
+ const rows = await tx
1168
+ .select()
1169
+ .from(personDocuments)
1170
+ .where(and(eq(personDocuments.workspaceId, input.workspaceId), eq(personDocuments.personId, input.personId)))
1171
+ .orderBy(desc(personDocuments.createdAt));
1172
+ return rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }));
1173
+ })),
1174
+ attach: scoped.documents.attach
1175
+ .use(cap('documents'))
1176
+ .use(requires('hr.document.manage'))
1177
+ .handler(async ({ input, context }) => {
1178
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1179
+ const [created] = await tx
1180
+ .insert(personDocuments)
1181
+ .values({
1182
+ id: uuidv7(),
1183
+ workspaceId: input.workspaceId,
1184
+ personId: input.personId,
1185
+ fileId: input.fileId,
1186
+ name: input.name,
1187
+ kind: input.kind,
1188
+ issuedOn: input.issuedOn ?? null,
1189
+ expiresOn: input.expiresOn ?? null,
1190
+ uploadedBy: context.principal.userId ?? null,
1191
+ })
1192
+ .returning();
1193
+ // The document's *existence* is audited, never its contents.
1194
+ await svc.record(tx, input.workspaceId, input.personId, context.principal.userId ?? null, [
1195
+ { field: 'document.attached', from: null, to: input.name },
1196
+ ]);
1197
+ return created;
1198
+ });
1199
+ await changed(input.workspaceId, 'person', input.personId, 'updated');
1200
+ return { ...row, createdAt: row.createdAt.toISOString() };
1201
+ }),
1202
+ remove: scoped.documents.remove
1203
+ .use(cap('documents'))
1204
+ .use(requires('hr.document.manage'))
1205
+ .handler(async ({ input, context }) => {
1206
+ await db.withWorkspace(input.workspaceId, async (tx) => {
1207
+ const [row] = await tx
1208
+ .select()
1209
+ .from(personDocuments)
1210
+ .where(and(eq(personDocuments.workspaceId, input.workspaceId), eq(personDocuments.id, input.documentId)))
1211
+ .limit(1);
1212
+ if (!row)
1213
+ throw KernError.notFound('Document');
1214
+ await tx.delete(personDocuments).where(eq(personDocuments.id, row.id));
1215
+ await svc.record(tx, input.workspaceId, input.personId, context.principal.userId ?? null, [
1216
+ { field: 'document.removed', from: row.name, to: null },
1217
+ ]);
1218
+ });
1219
+ await changed(input.workspaceId, 'person', input.personId, 'updated');
1220
+ return { ok: true };
1221
+ }),
1222
+ },
1223
+ // ================================================================= custom fields
1224
+ fields: {
1225
+ list: scoped.fields.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1226
+ const where = [eq(customFieldDefs.workspaceId, input.workspaceId)];
1227
+ if (!input.includeArchived)
1228
+ where.push(isNull(customFieldDefs.archivedAt));
1229
+ const rows = await tx
1230
+ .select()
1231
+ .from(customFieldDefs)
1232
+ .where(and(...where))
1233
+ .orderBy(asc(customFieldDefs.order), asc(customFieldDefs.name));
1234
+ return rows.map(toField);
1235
+ })),
1236
+ create: scoped.fields.create.use(requires('hr.field.manage')).handler(async ({ input }) => {
1237
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1238
+ const [created] = await tx
1239
+ .insert(customFieldDefs)
1240
+ .values({
1241
+ id: uuidv7(),
1242
+ workspaceId: input.workspaceId,
1243
+ key: input.key,
1244
+ name: input.name,
1245
+ type: input.type,
1246
+ options: input.options ?? null,
1247
+ required: input.required,
1248
+ sensitive: input.sensitive,
1249
+ section: input.section,
1250
+ })
1251
+ .returning();
1252
+ return created;
1253
+ });
1254
+ await changed(input.workspaceId, 'field', row.id, 'created');
1255
+ return toField(row);
1256
+ }),
1257
+ update: scoped.fields.update.use(requires('hr.field.manage')).handler(async ({ input }) => {
1258
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1259
+ const { workspaceId, fieldId, ...patch } = input;
1260
+ const set = {};
1261
+ for (const [k, v] of Object.entries(patch))
1262
+ if (v !== undefined)
1263
+ set[k] = v;
1264
+ const [updated] = await tx
1265
+ .update(customFieldDefs)
1266
+ .set(set)
1267
+ .where(and(eq(customFieldDefs.workspaceId, workspaceId), eq(customFieldDefs.id, fieldId)))
1268
+ .returning();
1269
+ if (!updated)
1270
+ throw KernError.notFound('Field');
1271
+ return updated;
1272
+ });
1273
+ await changed(input.workspaceId, 'field', row.id, 'updated');
1274
+ return toField(row);
1275
+ }),
1276
+ archive: scoped.fields.archive.use(requires('hr.field.manage')).handler(async ({ input }) => {
1277
+ // Archived, never dropped: the values stay in `people.custom`, so re-enabling the field
1278
+ // brings back what everybody had rather than a column of blanks.
1279
+ await db.withWorkspace(input.workspaceId, (tx) => tx
1280
+ .update(customFieldDefs)
1281
+ .set({ archivedAt: new Date() })
1282
+ .where(and(eq(customFieldDefs.workspaceId, input.workspaceId), eq(customFieldDefs.id, input.fieldId))));
1283
+ await changed(input.workspaceId, 'field', input.fieldId, 'deleted');
1284
+ return { ok: true };
1285
+ }),
1286
+ },
1287
+ });
1288
+ // ------------------------------------------------------------------ helpers
1289
+ // Closures over `kernel` and `db`, kept at the bottom so the router above reads as a list of
1290
+ // procedures rather than a list of procedures interrupted by plumbing.
1291
+ async function loadOffice(tx, input) {
1292
+ const [row] = await tx
1293
+ .select()
1294
+ .from(offices)
1295
+ .where(and(eq(offices.workspaceId, input.workspaceId), eq(offices.id, input.officeId)))
1296
+ .limit(1);
1297
+ if (!row)
1298
+ throw KernError.notFound('Office');
1299
+ return row;
1300
+ }
1301
+ async function loadCalendar(tx, workspaceId, calendarId) {
1302
+ const [row] = await tx
1303
+ .select()
1304
+ .from(calendars)
1305
+ .where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, calendarId)))
1306
+ .limit(1);
1307
+ if (!row)
1308
+ throw KernError.notFound('Calendar');
1309
+ return row;
1310
+ }
1311
+ /** The workspace's calendar for a country pack, created on first use so offices can share one. */
1312
+ async function packCalendar(tx, workspaceId, country) {
1313
+ const [existing] = await tx
1314
+ .select()
1315
+ .from(calendars)
1316
+ .where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.source, 'pack'), eq(calendars.packKey, country)))
1317
+ .limit(1);
1318
+ if (existing)
1319
+ return existing;
1320
+ const pack = COUNTRY_PACKS[country];
1321
+ if (!pack)
1322
+ return undefined;
1323
+ const [created] = await tx
1324
+ .insert(calendars)
1325
+ .values({
1326
+ id: uuidv7(),
1327
+ workspaceId,
1328
+ name: pack.name,
1329
+ country,
1330
+ workingWeek: pack.workingWeek,
1331
+ source: 'pack',
1332
+ packKey: country,
1333
+ })
1334
+ .returning();
1335
+ const year = new Date().getUTCFullYear();
1336
+ const days = packDays(country, year);
1337
+ if (days.length)
1338
+ await tx.insert(calendarDays).values(days.map((d) => ({
1339
+ id: uuidv7(),
1340
+ workspaceId,
1341
+ calendarId: created.id,
1342
+ date: d.date,
1343
+ kind: d.kind,
1344
+ name: d.name,
1345
+ workingFraction: String(d.workingFraction),
1346
+ source: 'pack',
1347
+ paid: true,
1348
+ })));
1349
+ return created;
1350
+ }
1351
+ /**
1352
+ * Walk `extends` and refuse a chain deeper than three, or one that would close a cycle.
1353
+ *
1354
+ * A cycle here does not throw anywhere obvious — it makes every calendar read spin, so the first
1355
+ * symptom is a report that never returns. Checked on write, where it is cheap and the person who
1356
+ * caused it is still looking at the screen.
1357
+ */
1358
+ async function assertChainDepth(tx, workspaceId, startId, selfId) {
1359
+ let cursor = startId;
1360
+ for (let depth = 0; depth < 4; depth++) {
1361
+ if (!cursor)
1362
+ return;
1363
+ if (selfId && cursor === selfId)
1364
+ throw KernError.badRequest('That would make the calendars extend each other in a circle.');
1365
+ const row = (await tx
1366
+ .select({ extendsId: calendars.extendsId })
1367
+ .from(calendars)
1368
+ .where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, cursor)))
1369
+ .limit(1))[0];
1370
+ cursor = row?.extendsId ?? null;
1371
+ }
1372
+ throw KernError.badRequest('Calendars may only be built on three levels.');
1373
+ }
1374
+ /** The chain nearest-first: this calendar, then whatever it extends. */
1375
+ async function calendarChain(tx, workspaceId, calendarId) {
1376
+ const chain = [];
1377
+ let cursor = calendarId;
1378
+ for (let depth = 0; depth < 4 && cursor; depth++) {
1379
+ const row = await loadCalendar(tx, workspaceId, cursor);
1380
+ chain.push(row);
1381
+ cursor = row.extendsId;
1382
+ }
1383
+ return chain;
1384
+ }
1385
+ /**
1386
+ * The composed calendar over a range: this calendar's days over the ones it extends.
1387
+ *
1388
+ * Nearest wins per date and kind, and a day that shadows one from a calendar further down is
1389
+ * marked `overrides` so the editor can show what it is replacing — which is what makes "we work
1390
+ * through this national holiday" legible rather than looking like a missing holiday.
1391
+ */
1392
+ async function composedDays(tx, workspaceId, calendarId, from, to) {
1393
+ const chain = await calendarChain(tx, workspaceId, calendarId);
1394
+ const rows = await tx
1395
+ .select()
1396
+ .from(calendarDays)
1397
+ .where(and(eq(calendarDays.workspaceId, workspaceId), inArray(calendarDays.calendarId, chain.map((c) => c.id)), gte(calendarDays.date, from), lte(calendarDays.date, to)));
1398
+ const nameById = new Map(chain.map((c) => [c.id, c.name]));
1399
+ const seen = new Map();
1400
+ const datesFromNearest = new Set();
1401
+ for (const cal of chain) {
1402
+ for (const row of rows.filter((r) => r.calendarId === cal.id)) {
1403
+ const key = `${row.date}:${row.kind}`;
1404
+ if (seen.has(key))
1405
+ continue;
1406
+ const overrides = cal.id !== calendarId ? false : datesFromNearest.has(row.date);
1407
+ seen.set(key, toResolvedDay(row, cal.id, nameById.get(cal.id) ?? '', overrides));
1408
+ if (cal.id === calendarId)
1409
+ datesFromNearest.add(row.date);
1410
+ }
1411
+ }
1412
+ // Second pass: a nearest-calendar day covering a date the base also has *is* an override, and
1413
+ // the first pass cannot know that until the base has been read.
1414
+ const baseDates = new Set(rows.filter((r) => r.calendarId !== calendarId).map((r) => r.date));
1415
+ return [...seen.values()]
1416
+ .map((d) => ({ ...d, overrides: d.fromCalendarId === calendarId && baseDates.has(d.date) }))
1417
+ .sort((a, b) => a.date.localeCompare(b.date));
1418
+ }
1419
+ /**
1420
+ * What applying a pack would do — and, just as importantly, what it would leave alone.
1421
+ *
1422
+ * The "kept" list is not decoration. The single most damaging thing this module could do is eat a
1423
+ * company's own holidays during a routine yearly refresh, silently, months before anyone notices.
1424
+ * Showing exactly which days survive is how an administrator can believe the operation.
1425
+ */
1426
+ async function diffPack(tx, workspaceId, calendarId, packKey, year) {
1427
+ const from = `${year}-01-01`;
1428
+ const to = `${year}-12-31`;
1429
+ const existing = await tx
1430
+ .select()
1431
+ .from(calendarDays)
1432
+ .where(and(eq(calendarDays.workspaceId, workspaceId), eq(calendarDays.calendarId, calendarId), gte(calendarDays.date, from), lte(calendarDays.date, to)));
1433
+ const currentPack = new Map(existing.filter((d) => d.source === 'pack').map((d) => [d.date, d]));
1434
+ const custom = existing.filter((d) => d.source === 'custom');
1435
+ const incoming = packDays(packKey, year);
1436
+ const incomingByDate = new Map(incoming.map((d) => [d.date, d]));
1437
+ return {
1438
+ packKey,
1439
+ packVersion: String(year),
1440
+ added: incoming.filter((d) => !currentPack.has(d.date)).map((d) => ({ date: d.date, name: d.name })),
1441
+ changed: incoming
1442
+ .filter((d) => currentPack.has(d.date) && currentPack.get(d.date).name !== d.name)
1443
+ .map((d) => ({ date: d.date, name: d.name, was: currentPack.get(d.date).name })),
1444
+ removed: [...currentPack.values()]
1445
+ .filter((d) => !incomingByDate.has(d.date))
1446
+ .map((d) => ({ date: d.date, name: d.name })),
1447
+ keptCustom: custom.map((d) => ({ date: d.date, name: d.name })),
1448
+ };
1449
+ }
1450
+ /** People in a department, optionally including everything beneath it. */
1451
+ async function unitMemberIds(tx, workspaceId, unitId, includeDescendants) {
1452
+ let unitIds = [unitId];
1453
+ if (includeDescendants) {
1454
+ const [unit] = await tx
1455
+ .select({ path: orgUnits.path })
1456
+ .from(orgUnits)
1457
+ .where(and(eq(orgUnits.workspaceId, workspaceId), eq(orgUnits.id, unitId)))
1458
+ .limit(1);
1459
+ if (!unit)
1460
+ return [];
1461
+ // One GiST index lookup for the whole subtree, rather than a recursive walk.
1462
+ const subtree = await tx
1463
+ .select({ id: orgUnits.id })
1464
+ .from(orgUnits)
1465
+ .where(and(eq(orgUnits.workspaceId, workspaceId), sql `path <@ ${unit.path}::ltree`));
1466
+ unitIds = subtree.map((u) => u.id);
1467
+ }
1468
+ if (!unitIds.length)
1469
+ return [];
1470
+ const rows = await tx
1471
+ .select({ personId: employments.personId })
1472
+ .from(employments)
1473
+ .where(and(eq(employments.workspaceId, workspaceId), inArray(employments.orgUnitId, unitIds), isNull(employments.effectiveTo)));
1474
+ return rows.map((r) => r.personId);
1475
+ }
1476
+ async function parentPath(tx, workspaceId, parentId) {
1477
+ if (!parentId)
1478
+ return null;
1479
+ const [row] = await tx
1480
+ .select({ path: orgUnits.path })
1481
+ .from(orgUnits)
1482
+ .where(and(eq(orgUnits.workspaceId, workspaceId), eq(orgUnits.id, parentId)))
1483
+ .limit(1);
1484
+ return row?.path ?? null;
1485
+ }
1486
+ /**
1487
+ * An ltree label for a new unit.
1488
+ *
1489
+ * The id with dashes stripped, not the name: ltree labels allow only letters, digits and
1490
+ * underscores, and a department called "R&D — Europe" would otherwise be unrepresentable. Names
1491
+ * change too, and a path built from one would have to be rewritten on every rename.
1492
+ */
1493
+ async function childPath(tx, workspaceId, parentId, id) {
1494
+ const label = `u${id.replace(/-/g, '')}`;
1495
+ const parent = await parentPath(tx, workspaceId, parentId);
1496
+ return parent ? `${parent}.${label}` : label;
1497
+ }
1498
+ async function emitCalendarChanged(workspaceId, calendarId, from, to, actorId) {
1499
+ await kernel.emit(hrEvents.calendarChanged, { calendarId, workspaceId, from, to }, { workspaceId, actorId });
1500
+ await changed(workspaceId, 'calendar', calendarId, 'updated');
1501
+ }
1502
+ }
1503
+ // ---------------------------------------------------------------------- serialisers
1504
+ const toOffice = (r) => ({
1505
+ ...r,
1506
+ kind: r.kind,
1507
+ address: r.address ?? null,
1508
+ archivedAt: r.archivedAt?.toISOString() ?? null,
1509
+ createdAt: r.createdAt.toISOString(),
1510
+ });
1511
+ const toEntity = (r) => ({
1512
+ ...r,
1513
+ archivedAt: r.archivedAt?.toISOString() ?? null,
1514
+ });
1515
+ const toCalendar = (r) => ({
1516
+ ...r,
1517
+ workingWeek: r.workingWeek,
1518
+ source: r.source,
1519
+ archivedAt: r.archivedAt?.toISOString() ?? null,
1520
+ });
1521
+ const toField = (r) => ({
1522
+ ...r,
1523
+ type: r.type,
1524
+ section: r.section,
1525
+ options: r.options ?? null,
1526
+ archivedAt: r.archivedAt?.toISOString() ?? null,
1527
+ });
1528
+ const toResolvedDay = (r, fromCalendarId, fromCalendarName, overrides) => ({
1529
+ ...r,
1530
+ kind: r.kind,
1531
+ source: r.source,
1532
+ workingFraction: Number.parseFloat(r.workingFraction),
1533
+ fromCalendarId,
1534
+ fromCalendarName,
1535
+ overrides,
1536
+ });
1537
+ //# sourceMappingURL=router.js.map