@kernhq/module-hr 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client/mock.ts +467 -2
package/package.json
CHANGED
package/src/client/mock.ts
CHANGED
|
@@ -32,7 +32,7 @@ import type {
|
|
|
32
32
|
ResolvedCalendarDay,
|
|
33
33
|
WorkingWeek,
|
|
34
34
|
} from '../contract/models.js'
|
|
35
|
-
import type { Period } from '../contract/policies.js'
|
|
35
|
+
import type { Period, Policy, PolicyAssignment, PolicySubjectKind } from '../contract/policies.js'
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* A refusal the client cannot tell from the server's.
|
|
@@ -254,6 +254,7 @@ export function createMockHrApi() {
|
|
|
254
254
|
{
|
|
255
255
|
id: id('d001'),
|
|
256
256
|
displayName: 'Ayşe Yılmaz',
|
|
257
|
+
hiredOn: day(-400),
|
|
257
258
|
workEmail: 'ayse@example.test',
|
|
258
259
|
status: 'active',
|
|
259
260
|
timezone: 'Europe/Istanbul',
|
|
@@ -262,6 +263,7 @@ export function createMockHrApi() {
|
|
|
262
263
|
{
|
|
263
264
|
id: id('d002'),
|
|
264
265
|
displayName: 'Sanne de Vries',
|
|
266
|
+
hiredOn: day(-300),
|
|
265
267
|
workEmail: 'sanne@example.test',
|
|
266
268
|
status: 'active',
|
|
267
269
|
timezone: 'Europe/Amsterdam',
|
|
@@ -270,6 +272,7 @@ export function createMockHrApi() {
|
|
|
270
272
|
{
|
|
271
273
|
id: id('d003'),
|
|
272
274
|
displayName: 'Mehmet Kaya',
|
|
275
|
+
hiredOn: day(-2000),
|
|
273
276
|
workEmail: 'mehmet@example.test',
|
|
274
277
|
status: 'on_leave',
|
|
275
278
|
timezone: 'Europe/Istanbul',
|
|
@@ -278,6 +281,7 @@ export function createMockHrApi() {
|
|
|
278
281
|
{
|
|
279
282
|
id: id('d004'),
|
|
280
283
|
displayName: 'Jonas Weber',
|
|
284
|
+
hiredOn: day(-80),
|
|
281
285
|
workEmail: 'jonas@example.test',
|
|
282
286
|
status: 'active',
|
|
283
287
|
timezone: 'Europe/Berlin',
|
|
@@ -369,7 +373,6 @@ export function createMockHrApi() {
|
|
|
369
373
|
personalEmail: null,
|
|
370
374
|
phone: null,
|
|
371
375
|
photoFileId: null,
|
|
372
|
-
hiredOn: day(-400),
|
|
373
376
|
terminatedOn: null,
|
|
374
377
|
custom: {},
|
|
375
378
|
createdAt: iso(400 * 86_400_000),
|
|
@@ -1149,6 +1152,270 @@ export function createMockHrApi() {
|
|
|
1149
1152
|
return weekday !== 'sat' && weekday !== 'sun' && date <= day(0)
|
|
1150
1153
|
}).length
|
|
1151
1154
|
|
|
1155
|
+
// ---------------------------------------------------------------- accrual policies
|
|
1156
|
+
|
|
1157
|
+
/** The ladder made explicit, straight from `SUBJECT_PRIORITY` in the contract. */
|
|
1158
|
+
const PRIORITY: Record<PolicySubjectKind, number> = {
|
|
1159
|
+
person: 100,
|
|
1160
|
+
office: 80,
|
|
1161
|
+
legal_entity: 60,
|
|
1162
|
+
org_unit: 40,
|
|
1163
|
+
position: 30,
|
|
1164
|
+
workspace: 0,
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Two policies that differ in more than their name.
|
|
1169
|
+
*
|
|
1170
|
+
* A list whose column reads the same sentence twice demonstrates nothing — these take different
|
|
1171
|
+
* branches of the evaluator: one accrues monthly with a waiting period for joiners, the other on
|
|
1172
|
+
* the employee's own anniversary with seniority tiers.
|
|
1173
|
+
*/
|
|
1174
|
+
const policies: Row<Policy>[] = [
|
|
1175
|
+
{
|
|
1176
|
+
id: id('b011'),
|
|
1177
|
+
kind: 'accrual',
|
|
1178
|
+
name: 'Monthly accrual',
|
|
1179
|
+
config: {
|
|
1180
|
+
frequency: 'monthly',
|
|
1181
|
+
daysPerYear: 20,
|
|
1182
|
+
minutesPerDay: 480,
|
|
1183
|
+
seniorityTiers: [],
|
|
1184
|
+
waitingPeriodMonths: 3,
|
|
1185
|
+
calendar: 'gregorian',
|
|
1186
|
+
roundToMinutes: 30,
|
|
1187
|
+
leaveTypeKey: 'annual',
|
|
1188
|
+
},
|
|
1189
|
+
effectiveFrom: `${YEAR}-01-01`,
|
|
1190
|
+
effectiveTo: null,
|
|
1191
|
+
source: 'custom',
|
|
1192
|
+
packKey: null,
|
|
1193
|
+
configHash: 'a1b2c3d4',
|
|
1194
|
+
archivedAt: null,
|
|
1195
|
+
},
|
|
1196
|
+
{
|
|
1197
|
+
id: id('b012'),
|
|
1198
|
+
kind: 'accrual',
|
|
1199
|
+
name: 'Anniversary accrual, with seniority',
|
|
1200
|
+
config: {
|
|
1201
|
+
frequency: 'anniversary',
|
|
1202
|
+
daysPerYear: 22,
|
|
1203
|
+
minutesPerDay: 480,
|
|
1204
|
+
seniorityTiers: [
|
|
1205
|
+
{ afterYears: 5, daysPerYear: 26 },
|
|
1206
|
+
{ afterYears: 10, daysPerYear: 30 },
|
|
1207
|
+
],
|
|
1208
|
+
waitingPeriodMonths: 0,
|
|
1209
|
+
calendar: 'gregorian',
|
|
1210
|
+
roundToMinutes: 0,
|
|
1211
|
+
leaveTypeKey: 'annual',
|
|
1212
|
+
},
|
|
1213
|
+
effectiveFrom: `${YEAR}-01-01`,
|
|
1214
|
+
effectiveTo: null,
|
|
1215
|
+
source: 'custom',
|
|
1216
|
+
packKey: null,
|
|
1217
|
+
configHash: 'e5f6a7b8',
|
|
1218
|
+
archivedAt: null,
|
|
1219
|
+
},
|
|
1220
|
+
]
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Two rungs, on purpose.
|
|
1224
|
+
*
|
|
1225
|
+
* The screen's copy says the nearest subject wins; with everything assigned at one rung nothing on
|
|
1226
|
+
* screen tests that claim. The workspace-wide monthly policy is what most people get, and Sanne
|
|
1227
|
+
* has the anniversary one at the `person` rung — which must visibly beat the office below it.
|
|
1228
|
+
*/
|
|
1229
|
+
const policyAssignments: Row<PolicyAssignment>[] = [
|
|
1230
|
+
{
|
|
1231
|
+
id: id('b0a1'),
|
|
1232
|
+
policyId: id('b011'),
|
|
1233
|
+
subjectKind: 'workspace',
|
|
1234
|
+
subjectId: null,
|
|
1235
|
+
effectiveFrom: `${YEAR}-01-01`,
|
|
1236
|
+
effectiveTo: null,
|
|
1237
|
+
priority: PRIORITY.workspace,
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
id: id('b0a2'),
|
|
1241
|
+
policyId: id('b012'),
|
|
1242
|
+
subjectKind: 'office',
|
|
1243
|
+
subjectId: id('e002'),
|
|
1244
|
+
effectiveFrom: `${YEAR}-01-01`,
|
|
1245
|
+
effectiveTo: null,
|
|
1246
|
+
priority: PRIORITY.office,
|
|
1247
|
+
},
|
|
1248
|
+
// Mehmet has been here five years, so the tier above `afterYears: 5` is the one that answers
|
|
1249
|
+
// for him — without somebody long-serving the seniority branch of the evaluator never fires and
|
|
1250
|
+
// the second policy's whole reason for existing goes untested.
|
|
1251
|
+
{
|
|
1252
|
+
id: id('b0a4'),
|
|
1253
|
+
policyId: id('b012'),
|
|
1254
|
+
subjectKind: 'person',
|
|
1255
|
+
subjectId: people[2]!.id,
|
|
1256
|
+
effectiveFrom: `${YEAR}-01-01`,
|
|
1257
|
+
effectiveTo: null,
|
|
1258
|
+
priority: PRIORITY.person,
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
id: id('b0a3'),
|
|
1262
|
+
policyId: id('b012'),
|
|
1263
|
+
subjectKind: 'person',
|
|
1264
|
+
subjectId: people[1]!.id,
|
|
1265
|
+
effectiveFrom: `${YEAR}-01-01`,
|
|
1266
|
+
effectiveTo: null,
|
|
1267
|
+
priority: PRIORITY.person,
|
|
1268
|
+
},
|
|
1269
|
+
]
|
|
1270
|
+
|
|
1271
|
+
const assignmentsOf = (policyId: string) => policyAssignments.filter((a) => a.policyId === policyId)
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Which policy applies to somebody, and which rung answered.
|
|
1275
|
+
*
|
|
1276
|
+
* Nearest wins, by the priority the contract publishes — so a policy given to one person beats the
|
|
1277
|
+
* one on their office, which beats their legal entity, their department, their position, and
|
|
1278
|
+
* finally the whole workspace. This is the claim the screen's copy makes, so it has to be the
|
|
1279
|
+
* claim the fixture keeps.
|
|
1280
|
+
*/
|
|
1281
|
+
function resolvePolicyFor(personId: string) {
|
|
1282
|
+
const officeId = primaryOfficeId(personId)
|
|
1283
|
+
const job = employments.find((e) => e.personId === personId && e.effectiveTo === null)
|
|
1284
|
+
const matches = (a: Row<PolicyAssignment>) => {
|
|
1285
|
+
switch (a.subjectKind) {
|
|
1286
|
+
case 'person':
|
|
1287
|
+
return a.subjectId === personId
|
|
1288
|
+
case 'office':
|
|
1289
|
+
return a.subjectId === officeId
|
|
1290
|
+
case 'legal_entity':
|
|
1291
|
+
return a.subjectId === job?.legalEntityId
|
|
1292
|
+
case 'org_unit':
|
|
1293
|
+
return a.subjectId === job?.orgUnitId
|
|
1294
|
+
case 'position':
|
|
1295
|
+
return a.subjectId === job?.positionId
|
|
1296
|
+
default:
|
|
1297
|
+
return true
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const best = policyAssignments
|
|
1301
|
+
.filter((a) => {
|
|
1302
|
+
if (a.effectiveTo !== null) return false
|
|
1303
|
+
const policy = policies.find((x) => x.id === a.policyId)
|
|
1304
|
+
return Boolean(policy && policy.archivedAt === null) && matches(a)
|
|
1305
|
+
})
|
|
1306
|
+
.sort((a, b) => b.priority - a.priority)[0]
|
|
1307
|
+
const policy = best ? policies.find((x) => x.id === best.policyId) : undefined
|
|
1308
|
+
return best && policy ? { assignment: best, policy } : null
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const wholeMonthsBetween = (from: string, to: string) => {
|
|
1312
|
+
const a = new Date(`${from}T00:00:00Z`)
|
|
1313
|
+
const b = new Date(`${to}T00:00:00Z`)
|
|
1314
|
+
let months = (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth())
|
|
1315
|
+
if (b.getUTCDate() < a.getUTCDate()) months -= 1
|
|
1316
|
+
return months
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* What a run would credit — computed, never written.
|
|
1321
|
+
*
|
|
1322
|
+
* `preview` and `run` share this function for the reason the contract gives: a preview computed
|
|
1323
|
+
* differently from the thing it previews is a preview that eventually lies. Only `run` writes,
|
|
1324
|
+
* and it writes from these rows.
|
|
1325
|
+
*/
|
|
1326
|
+
function accrualRows(from: string, to: string, onlyPersonId?: string) {
|
|
1327
|
+
const rows: Array<{
|
|
1328
|
+
personId: string
|
|
1329
|
+
displayName: string
|
|
1330
|
+
leaveTypeId: string
|
|
1331
|
+
leaveTypeName: string
|
|
1332
|
+
minutes: number
|
|
1333
|
+
days: number
|
|
1334
|
+
reason: string
|
|
1335
|
+
alreadyAccrued: boolean
|
|
1336
|
+
}> = []
|
|
1337
|
+
const skipped: Array<{ personId: string; displayName: string; reason: string }> = []
|
|
1338
|
+
|
|
1339
|
+
for (const who of people) {
|
|
1340
|
+
if (onlyPersonId && who.id !== onlyPersonId) continue
|
|
1341
|
+
const skip = (reason: string) =>
|
|
1342
|
+
skipped.push({ personId: who.id, displayName: who.displayName, reason })
|
|
1343
|
+
|
|
1344
|
+
if (who.status === 'terminated') {
|
|
1345
|
+
skip('No longer employed')
|
|
1346
|
+
continue
|
|
1347
|
+
}
|
|
1348
|
+
const applicable = resolvePolicyFor(who.id)
|
|
1349
|
+
if (!applicable) {
|
|
1350
|
+
skip('No accrual policy applies')
|
|
1351
|
+
continue
|
|
1352
|
+
}
|
|
1353
|
+
const config = applicable.policy.config as {
|
|
1354
|
+
frequency: string
|
|
1355
|
+
daysPerYear: number
|
|
1356
|
+
minutesPerDay: number
|
|
1357
|
+
seniorityTiers: Array<{ afterYears: number; daysPerYear: number }>
|
|
1358
|
+
waitingPeriodMonths: number
|
|
1359
|
+
roundToMinutes: number
|
|
1360
|
+
leaveTypeKey: string
|
|
1361
|
+
}
|
|
1362
|
+
const served = wholeMonthsBetween(who.hiredOn, from)
|
|
1363
|
+
if (served < config.waitingPeriodMonths) {
|
|
1364
|
+
skip(`Within the ${config.waitingPeriodMonths}-month waiting period — ${served} served`)
|
|
1365
|
+
continue
|
|
1366
|
+
}
|
|
1367
|
+
const leaveType = leaveTypes.find((lt) => lt.key === config.leaveTypeKey && lt.archivedAt === null)
|
|
1368
|
+
if (!leaveType) {
|
|
1369
|
+
skip(`No leave type keyed "${config.leaveTypeKey}"`)
|
|
1370
|
+
continue
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Most senior tier reached wins, whatever order they are written in.
|
|
1374
|
+
const years = Math.floor(served / 12)
|
|
1375
|
+
const tier = [...(config.seniorityTiers ?? [])]
|
|
1376
|
+
.filter((t) => years >= t.afterYears)
|
|
1377
|
+
.sort((a, b) => b.afterYears - a.afterYears)[0]
|
|
1378
|
+
const perYear = tier?.daysPerYear ?? config.daysPerYear
|
|
1379
|
+
|
|
1380
|
+
let minutes = Math.round((perYear * config.minutesPerDay) / 12)
|
|
1381
|
+
if (config.roundToMinutes > 0) {
|
|
1382
|
+
minutes = Math.round(minutes / config.roundToMinutes) * config.roundToMinutes
|
|
1383
|
+
}
|
|
1384
|
+
const alreadyAccrued = ledger.some(
|
|
1385
|
+
(e) =>
|
|
1386
|
+
e.personId === who.id &&
|
|
1387
|
+
e.leaveTypeId === leaveType.id &&
|
|
1388
|
+
e.kind === 'accrual' &&
|
|
1389
|
+
e.effectiveOn >= from &&
|
|
1390
|
+
e.effectiveOn <= to,
|
|
1391
|
+
)
|
|
1392
|
+
const basis = tier
|
|
1393
|
+
? `${perYear} days a year after ${tier.afterYears} years' service`
|
|
1394
|
+
: `${perYear} days a year`
|
|
1395
|
+
rows.push({
|
|
1396
|
+
personId: who.id,
|
|
1397
|
+
displayName: who.displayName,
|
|
1398
|
+
leaveTypeId: leaveType.id,
|
|
1399
|
+
leaveTypeName: leaveType.name,
|
|
1400
|
+
minutes,
|
|
1401
|
+
days: Math.round((minutes / config.minutesPerDay) * 100) / 100,
|
|
1402
|
+
reason: alreadyAccrued
|
|
1403
|
+
? `${basis}, ${config.frequency} — already credited for this period`
|
|
1404
|
+
: `${basis}, ${config.frequency}`,
|
|
1405
|
+
alreadyAccrued,
|
|
1406
|
+
})
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return {
|
|
1410
|
+
periodFrom: from,
|
|
1411
|
+
periodTo: to,
|
|
1412
|
+
rows,
|
|
1413
|
+
// What the run would actually add: a row already credited contributes nothing to it.
|
|
1414
|
+
totalMinutes: rows.filter((r) => !r.alreadyAccrued).reduce((sum, r) => sum + r.minutes, 0),
|
|
1415
|
+
skipped,
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1152
1419
|
// ---------------------------------------------------------------- approval chains
|
|
1153
1420
|
|
|
1154
1421
|
const chains: Row<ApprovalChain>[] = [
|
|
@@ -1477,6 +1744,7 @@ export function createMockHrApi() {
|
|
|
1477
1744
|
workEmail: input.workEmail ?? '',
|
|
1478
1745
|
status: 'active' as const,
|
|
1479
1746
|
timezone: 'Europe/Istanbul',
|
|
1747
|
+
hiredOn: input.hiredOn ?? day(0),
|
|
1480
1748
|
employeeNo: input.employeeNo ?? `E-${people.length + 1}`,
|
|
1481
1749
|
}
|
|
1482
1750
|
people.push(added)
|
|
@@ -3037,6 +3305,203 @@ export function createMockHrApi() {
|
|
|
3037
3305
|
},
|
|
3038
3306
|
},
|
|
3039
3307
|
|
|
3308
|
+
policies: {
|
|
3309
|
+
list: async ({
|
|
3310
|
+
workspaceId,
|
|
3311
|
+
kind,
|
|
3312
|
+
includeArchived = false,
|
|
3313
|
+
}: {
|
|
3314
|
+
workspaceId: string
|
|
3315
|
+
kind?: string
|
|
3316
|
+
includeArchived?: boolean
|
|
3317
|
+
}) =>
|
|
3318
|
+
policies
|
|
3319
|
+
.filter((row) => (!kind || row.kind === kind) && (includeArchived || row.archivedAt === null))
|
|
3320
|
+
.map((row) => ({
|
|
3321
|
+
...row,
|
|
3322
|
+
workspaceId,
|
|
3323
|
+
assignments: assignmentsOf(row.id).map((a) => ({ ...a, workspaceId })),
|
|
3324
|
+
})),
|
|
3325
|
+
|
|
3326
|
+
get: async ({ workspaceId, policyId }: { workspaceId: string; policyId: string }) => {
|
|
3327
|
+
const found = policies.find((row) => row.id === policyId)
|
|
3328
|
+
if (!found) refuse('NOT_FOUND', 'Policy not found')
|
|
3329
|
+
return {
|
|
3330
|
+
...found,
|
|
3331
|
+
workspaceId,
|
|
3332
|
+
assignments: assignmentsOf(found.id).map((a) => ({ ...a, workspaceId })),
|
|
3333
|
+
}
|
|
3334
|
+
},
|
|
3335
|
+
|
|
3336
|
+
create: async (input: {
|
|
3337
|
+
workspaceId: string
|
|
3338
|
+
kind: Policy['kind']
|
|
3339
|
+
name: string
|
|
3340
|
+
config: Record<string, unknown>
|
|
3341
|
+
effectiveFrom: string
|
|
3342
|
+
effectiveTo?: string | null
|
|
3343
|
+
}) => {
|
|
3344
|
+
const created: Row<Policy> = {
|
|
3345
|
+
id: crypto.randomUUID(),
|
|
3346
|
+
kind: input.kind,
|
|
3347
|
+
name: input.name,
|
|
3348
|
+
config: clone(input.config),
|
|
3349
|
+
effectiveFrom: input.effectiveFrom,
|
|
3350
|
+
effectiveTo: input.effectiveTo ?? null,
|
|
3351
|
+
source: 'custom',
|
|
3352
|
+
packKey: null,
|
|
3353
|
+
// A hash of the config, because that is what a derived row records — a literal would make
|
|
3354
|
+
// every policy look identical to a recomputation deciding whether a figure is stale.
|
|
3355
|
+
configHash: Math.abs(
|
|
3356
|
+
[...JSON.stringify(input.config)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 7),
|
|
3357
|
+
)
|
|
3358
|
+
.toString(16)
|
|
3359
|
+
.padStart(8, '0'),
|
|
3360
|
+
archivedAt: null,
|
|
3361
|
+
}
|
|
3362
|
+
policies.push(created)
|
|
3363
|
+
return { ...created, workspaceId: input.workspaceId }
|
|
3364
|
+
},
|
|
3365
|
+
|
|
3366
|
+
update: async (input: {
|
|
3367
|
+
workspaceId: string
|
|
3368
|
+
policyId: string
|
|
3369
|
+
name?: string
|
|
3370
|
+
config?: Record<string, unknown>
|
|
3371
|
+
effectiveTo?: string | null
|
|
3372
|
+
}) => {
|
|
3373
|
+
const found = policies.find((row) => row.id === input.policyId)
|
|
3374
|
+
if (!found) refuse('NOT_FOUND', 'Policy not found')
|
|
3375
|
+
if (input.name !== undefined) found.name = input.name
|
|
3376
|
+
if (input.config !== undefined) found.config = clone(input.config)
|
|
3377
|
+
if (input.effectiveTo !== undefined) found.effectiveTo = input.effectiveTo
|
|
3378
|
+
return { ...found, workspaceId: input.workspaceId }
|
|
3379
|
+
},
|
|
3380
|
+
|
|
3381
|
+
/**
|
|
3382
|
+
* Archived, never deleted, and **the assignments are left where they are**.
|
|
3383
|
+
*
|
|
3384
|
+
* The router neither refuses nor cascades — a ledger entry names the policy that produced it,
|
|
3385
|
+
* so a movement whose policy had vanished would be a number nobody can explain. That means an
|
|
3386
|
+
* archived policy with live assignments is a state an administrator genuinely reaches, and the
|
|
3387
|
+
* fixture can reach it too rather than only in theory.
|
|
3388
|
+
*/
|
|
3389
|
+
archive: async ({ policyId }: { workspaceId: string; policyId: string }) => {
|
|
3390
|
+
const found = policies.find((row) => row.id === policyId)
|
|
3391
|
+
if (!found) refuse('NOT_FOUND', 'Policy not found')
|
|
3392
|
+
found.archivedAt = iso()
|
|
3393
|
+
return { ok: true as const }
|
|
3394
|
+
},
|
|
3395
|
+
|
|
3396
|
+
assign: async (input: {
|
|
3397
|
+
workspaceId: string
|
|
3398
|
+
policyId: string
|
|
3399
|
+
subjectKind: PolicySubjectKind
|
|
3400
|
+
subjectId?: string | null
|
|
3401
|
+
effectiveFrom: string
|
|
3402
|
+
effectiveTo?: string | null
|
|
3403
|
+
}) => {
|
|
3404
|
+
const created: Row<PolicyAssignment> = {
|
|
3405
|
+
id: crypto.randomUUID(),
|
|
3406
|
+
policyId: input.policyId,
|
|
3407
|
+
subjectKind: input.subjectKind,
|
|
3408
|
+
// `workspace` needs no id, and storing one would make the rung look narrower than it is.
|
|
3409
|
+
subjectId: input.subjectKind === 'workspace' ? null : (input.subjectId ?? null),
|
|
3410
|
+
effectiveFrom: input.effectiveFrom,
|
|
3411
|
+
effectiveTo: input.effectiveTo ?? null,
|
|
3412
|
+
priority: PRIORITY[input.subjectKind],
|
|
3413
|
+
}
|
|
3414
|
+
policyAssignments.push(created)
|
|
3415
|
+
return { ...created, workspaceId: input.workspaceId }
|
|
3416
|
+
},
|
|
3417
|
+
|
|
3418
|
+
unassign: async ({ assignmentId }: { workspaceId: string; assignmentId: string }) => {
|
|
3419
|
+
const at = policyAssignments.findIndex((a) => a.id === assignmentId)
|
|
3420
|
+
if (at < 0) refuse('NOT_FOUND', 'Policy assignment not found')
|
|
3421
|
+
policyAssignments.splice(at, 1)
|
|
3422
|
+
return { ok: true as const }
|
|
3423
|
+
},
|
|
3424
|
+
|
|
3425
|
+
resolveFor: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
|
|
3426
|
+
const applicable = resolvePolicyFor(personId)
|
|
3427
|
+
void workspaceId
|
|
3428
|
+
return applicable
|
|
3429
|
+
? [
|
|
3430
|
+
{
|
|
3431
|
+
kind: 'accrual' as const,
|
|
3432
|
+
policyId: applicable.policy.id,
|
|
3433
|
+
policyName: applicable.policy.name,
|
|
3434
|
+
config: applicable.policy.config,
|
|
3435
|
+
from: applicable.assignment.subjectKind,
|
|
3436
|
+
subjectId: applicable.assignment.subjectId,
|
|
3437
|
+
},
|
|
3438
|
+
]
|
|
3439
|
+
: []
|
|
3440
|
+
},
|
|
3441
|
+
},
|
|
3442
|
+
|
|
3443
|
+
accrual: {
|
|
3444
|
+
/**
|
|
3445
|
+
* A query, and it **writes nothing**.
|
|
3446
|
+
*
|
|
3447
|
+
* The contract makes it a query for exactly this reason: a preview that filed ledger entries
|
|
3448
|
+
* would credit everybody the moment somebody looked at the screen meant to tell them what a
|
|
3449
|
+
* run *would* do. It returns the same rows `run` credits from, so the two cannot disagree.
|
|
3450
|
+
*/
|
|
3451
|
+
preview: async ({
|
|
3452
|
+
workspaceId,
|
|
3453
|
+
from,
|
|
3454
|
+
to,
|
|
3455
|
+
personId,
|
|
3456
|
+
}: {
|
|
3457
|
+
workspaceId: string
|
|
3458
|
+
from: string
|
|
3459
|
+
to: string
|
|
3460
|
+
personId?: string
|
|
3461
|
+
}) => {
|
|
3462
|
+
void workspaceId
|
|
3463
|
+
return accrualRows(from, to, personId)
|
|
3464
|
+
},
|
|
3465
|
+
|
|
3466
|
+
/**
|
|
3467
|
+
* Credits the ledger, and is idempotent per person, per leave type, per period.
|
|
3468
|
+
*
|
|
3469
|
+
* A second run over the same window credits nothing, because every row it would write is
|
|
3470
|
+
* already `alreadyAccrued` — an accrual job that double-credits when somebody clicks twice is
|
|
3471
|
+
* worse than one that never ran.
|
|
3472
|
+
*/
|
|
3473
|
+
run: async ({
|
|
3474
|
+
workspaceId,
|
|
3475
|
+
from,
|
|
3476
|
+
to,
|
|
3477
|
+
personId,
|
|
3478
|
+
}: {
|
|
3479
|
+
workspaceId: string
|
|
3480
|
+
from: string
|
|
3481
|
+
to: string
|
|
3482
|
+
personId?: string
|
|
3483
|
+
}) => {
|
|
3484
|
+
void workspaceId
|
|
3485
|
+
const preview = accrualRows(from, to, personId)
|
|
3486
|
+
let credited = 0
|
|
3487
|
+
let totalMinutes = 0
|
|
3488
|
+
for (const row of preview.rows) {
|
|
3489
|
+
if (row.alreadyAccrued) continue
|
|
3490
|
+
ledger.push(
|
|
3491
|
+
entry(row.personId, row.leaveTypeId, 'accrual', row.minutes, to, { reason: row.reason }),
|
|
3492
|
+
)
|
|
3493
|
+
credited += 1
|
|
3494
|
+
totalMinutes += row.minutes
|
|
3495
|
+
}
|
|
3496
|
+
return {
|
|
3497
|
+
credited,
|
|
3498
|
+
// Everything the run passed over: those already credited, and those it never reached.
|
|
3499
|
+
skipped: preview.skipped.length + preview.rows.filter((r) => r.alreadyAccrued).length,
|
|
3500
|
+
totalMinutes,
|
|
3501
|
+
}
|
|
3502
|
+
},
|
|
3503
|
+
},
|
|
3504
|
+
|
|
3040
3505
|
periods: {
|
|
3041
3506
|
list: async ({
|
|
3042
3507
|
workspaceId,
|