@kernhq/module-hr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +662 -0
- package/README.md +71 -0
- package/dist/contract/capabilities.d.ts +45 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +114 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +74 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +63 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +457 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +381 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +174 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +209 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +2720 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +520 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +34 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/policy/calendar.d.ts +64 -0
- package/dist/policy/calendar.d.ts.map +1 -0
- package/dist/policy/calendar.js +113 -0
- package/dist/policy/calendar.js.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +141 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/packs/index.d.ts +50 -0
- package/dist/server/packs/index.d.ts.map +1 -0
- package/dist/server/packs/index.js +121 -0
- package/dist/server/packs/index.js.map +1 -0
- package/dist/server/router.d.ts +3095 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +1537 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +2828 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +313 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/db.d.ts +22 -0
- package/dist/server/services/db.d.ts.map +1 -0
- package/dist/server/services/db.js +23 -0
- package/dist/server/services/db.js.map +1 -0
- package/dist/server/services/people.d.ts +110 -0
- package/dist/server/services/people.d.ts.map +1 -0
- package/dist/server/services/people.js +182 -0
- package/dist/server/services/people.js.map +1 -0
- package/dist/server/services/resolve.d.ts +66 -0
- package/dist/server/services/resolve.d.ts.map +1 -0
- package/dist/server/services/resolve.js +145 -0
- package/dist/server/services/resolve.js.map +1 -0
- package/migrations/0000_init.sql +223 -0
- package/migrations/0001_rls.sql +141 -0
- package/migrations/meta/0000_snapshot.json +1715 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +59 -0
- package/src/client/index.ts +62 -0
- package/src/contract/capabilities.ts +117 -0
- package/src/contract/events.ts +84 -0
- package/src/contract/index.ts +14 -0
- package/src/contract/models.ts +437 -0
- package/src/contract/permissions.ts +217 -0
- package/src/contract/router.ts +611 -0
- package/src/contract/settings.ts +35 -0
|
@@ -0,0 +1,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
|