@kernhq/module-hr 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/contract/approvals.d.ts +281 -0
  2. package/dist/contract/approvals.d.ts.map +1 -0
  3. package/dist/contract/approvals.js +127 -0
  4. package/dist/contract/approvals.js.map +1 -0
  5. package/dist/contract/capabilities.d.ts +1 -1
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +44 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/events.d.ts +42 -0
  10. package/dist/contract/events.d.ts.map +1 -1
  11. package/dist/contract/events.js +42 -0
  12. package/dist/contract/events.js.map +1 -1
  13. package/dist/contract/index.d.ts +2 -0
  14. package/dist/contract/index.d.ts.map +1 -1
  15. package/dist/contract/index.js +2 -0
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/contract/leave.d.ts +168 -0
  18. package/dist/contract/leave.d.ts.map +1 -0
  19. package/dist/contract/leave.js +150 -0
  20. package/dist/contract/leave.js.map +1 -0
  21. package/dist/contract/permissions.d.ts +57 -0
  22. package/dist/contract/permissions.d.ts.map +1 -1
  23. package/dist/contract/permissions.js +69 -0
  24. package/dist/contract/permissions.js.map +1 -1
  25. package/dist/contract/router.d.ts +1446 -0
  26. package/dist/contract/router.d.ts.map +1 -1
  27. package/dist/contract/router.js +235 -0
  28. package/dist/contract/router.js.map +1 -1
  29. package/dist/server/router.d.ts +1646 -58
  30. package/dist/server/router.d.ts.map +1 -1
  31. package/dist/server/router.js +812 -2
  32. package/dist/server/router.js.map +1 -1
  33. package/dist/server/schema.d.ts +2084 -1
  34. package/dist/server/schema.d.ts.map +1 -1
  35. package/dist/server/schema.js +209 -0
  36. package/dist/server/schema.js.map +1 -1
  37. package/dist/server/services/approvals.d.ts +163 -0
  38. package/dist/server/services/approvals.d.ts.map +1 -0
  39. package/dist/server/services/approvals.js +325 -0
  40. package/dist/server/services/approvals.js.map +1 -0
  41. package/dist/server/services/ledger.d.ts +135 -0
  42. package/dist/server/services/ledger.d.ts.map +1 -0
  43. package/dist/server/services/ledger.js +178 -0
  44. package/dist/server/services/ledger.js.map +1 -0
  45. package/dist/server/services/people.d.ts +3 -3
  46. package/migrations/0002_leave.sql +237 -0
  47. package/migrations/meta/0002_snapshot.json +3009 -0
  48. package/migrations/meta/_journal.json +7 -0
  49. package/package.json +1 -1
  50. package/src/contract/approvals.ts +149 -0
  51. package/src/contract/capabilities.ts +44 -0
  52. package/src/contract/events.ts +57 -0
  53. package/src/contract/index.ts +2 -0
  54. package/src/contract/leave.ts +171 -0
  55. package/src/contract/permissions.ts +71 -0
  56. package/src/contract/router.ts +285 -0
@@ -1,11 +1,13 @@
1
1
  import { KernError, requires, requiresCapability, uuidv7, workspaceScoped, } from '@kernhq/kernel';
2
2
  import { implement } from '@orpc/server';
3
- import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, sql } from 'drizzle-orm';
3
+ import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, or, sql } from 'drizzle-orm';
4
4
  import { HrSettings, hrContract, hrEvents, MODULE_ID } from '../contract/index.js';
5
5
  import { countWorkingDays, workingDays } from '../policy/calendar.js';
6
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';
7
+ import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, personDocuments, personHistory, positions, } from './schema.js';
8
+ import { ApprovalService } from './services/approvals.js';
8
9
  import { inForceOn, todayIso } from './services/db.js';
10
+ import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
9
11
  import { PeopleService } from './services/people.js';
10
12
  import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
11
13
  const os = implement(hrContract).$context();
@@ -26,6 +28,8 @@ export function implement_(kernel) {
26
28
  const cap = (id) => requiresCapability(MODULE_ID, id);
27
29
  const resolve = new ResolveService();
28
30
  const svc = new PeopleService(kernel);
31
+ const ledger = new LedgerService();
32
+ const approvals = new ApprovalService(kernel);
29
33
  const db = kernel.database;
30
34
  const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
31
35
  const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
@@ -1220,6 +1224,569 @@ export function implement_(kernel) {
1220
1224
  return { ok: true };
1221
1225
  }),
1222
1226
  },
1227
+ // ================================================================= leave
1228
+ leave: {
1229
+ types: {
1230
+ list: scoped.leave.types.list
1231
+ .use(cap('leave'))
1232
+ .use(requires('hr.leave.view'))
1233
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1234
+ const where = [eq(leaveTypes.workspaceId, input.workspaceId)];
1235
+ if (!input.includeArchived)
1236
+ where.push(isNull(leaveTypes.archivedAt));
1237
+ const rows = await tx
1238
+ .select()
1239
+ .from(leaveTypes)
1240
+ .where(and(...where))
1241
+ .orderBy(asc(leaveTypes.order), asc(leaveTypes.name));
1242
+ return rows.map(toLeaveType);
1243
+ })),
1244
+ create: scoped.leave.types.create
1245
+ .use(cap('leave'))
1246
+ .use(requires('hr.leave.manage'))
1247
+ .handler(async ({ input }) => {
1248
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1249
+ const [created] = await tx
1250
+ .insert(leaveTypes)
1251
+ .values({
1252
+ id: uuidv7(),
1253
+ workspaceId: input.workspaceId,
1254
+ key: input.key,
1255
+ name: input.name,
1256
+ paid: input.paid,
1257
+ unit: input.unit,
1258
+ color: input.color ?? null,
1259
+ icon: input.icon ?? null,
1260
+ requiresDocumentAfterDays: input.requiresDocumentAfterDays ?? null,
1261
+ countsWorkingDaysOnly: input.countsWorkingDaysOnly,
1262
+ allowNegative: input.allowNegative,
1263
+ maxNegativeMinutes: input.maxNegativeMinutes,
1264
+ })
1265
+ .returning();
1266
+ return created;
1267
+ });
1268
+ await changed(input.workspaceId, 'leave_type', row.id, 'created');
1269
+ return toLeaveType(row);
1270
+ }),
1271
+ update: scoped.leave.types.update
1272
+ .use(cap('leave'))
1273
+ .use(requires('hr.leave.manage'))
1274
+ .handler(async ({ input }) => {
1275
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1276
+ const { workspaceId, leaveTypeId, ...patch } = input;
1277
+ const set = { updatedAt: new Date() };
1278
+ for (const [k, v] of Object.entries(patch))
1279
+ if (v !== undefined)
1280
+ set[k] = v;
1281
+ const [updated] = await tx
1282
+ .update(leaveTypes)
1283
+ .set(set)
1284
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, leaveTypeId)))
1285
+ .returning();
1286
+ if (!updated)
1287
+ throw KernError.notFound('Leave type');
1288
+ return updated;
1289
+ });
1290
+ await changed(input.workspaceId, 'leave_type', row.id, 'updated');
1291
+ return toLeaveType(row);
1292
+ }),
1293
+ archive: scoped.leave.types.archive
1294
+ .use(cap('leave'))
1295
+ .use(requires('hr.leave.manage'))
1296
+ .handler(async ({ input }) => {
1297
+ // Archived, never deleted: the ledger points at it, and a balance whose type has
1298
+ // vanished is a number nobody can explain.
1299
+ await db.withWorkspace(input.workspaceId, (tx) => tx
1300
+ .update(leaveTypes)
1301
+ .set({ archivedAt: new Date() })
1302
+ .where(and(eq(leaveTypes.workspaceId, input.workspaceId), eq(leaveTypes.id, input.leaveTypeId))));
1303
+ await changed(input.workspaceId, 'leave_type', input.leaveTypeId, 'deleted');
1304
+ return { ok: true };
1305
+ }),
1306
+ },
1307
+ balance: {
1308
+ get: scoped.leave.balance.get
1309
+ .use(cap('leave'))
1310
+ .use(requires('hr.leave.view'))
1311
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1312
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1313
+ const year = input.periodYear ?? new Date().getUTCFullYear();
1314
+ return ledger.balances(tx, input.workspaceId, personId, year);
1315
+ })),
1316
+ },
1317
+ ledger: {
1318
+ list: scoped.leave.ledger.list
1319
+ .use(cap('leave'))
1320
+ .use(requires('hr.leave.view_ledger'))
1321
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1322
+ const where = [
1323
+ eq(leaveLedger.workspaceId, input.workspaceId),
1324
+ eq(leaveLedger.personId, input.personId),
1325
+ ];
1326
+ if (input.leaveTypeId)
1327
+ where.push(eq(leaveLedger.leaveTypeId, input.leaveTypeId));
1328
+ if (input.periodYear)
1329
+ where.push(eq(leaveLedger.periodYear, input.periodYear));
1330
+ const rows = await tx
1331
+ .select()
1332
+ .from(leaveLedger)
1333
+ .where(and(...where))
1334
+ .orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.createdAt))
1335
+ .limit(input.limit);
1336
+ return { items: rows.map(toLedgerEntry), nextCursor: null };
1337
+ })),
1338
+ },
1339
+ adjust: scoped.leave.adjust
1340
+ .use(cap('leave'))
1341
+ .use(requires('hr.leave.adjust'))
1342
+ .handler(async ({ input, context }) => {
1343
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1344
+ const year = yearOf(input.effectiveOn);
1345
+ await ledger.lockAndRead(tx, input.workspaceId, input.personId, input.leaveTypeId, year);
1346
+ return ledger.append(tx, input.workspaceId, {
1347
+ personId: input.personId,
1348
+ leaveTypeId: input.leaveTypeId,
1349
+ kind: input.kind,
1350
+ amountMinutes: input.amountMinutes,
1351
+ effectiveOn: input.effectiveOn,
1352
+ periodYear: year,
1353
+ reason: input.reason,
1354
+ createdBy: context.principal.userId ?? null,
1355
+ });
1356
+ });
1357
+ await kernel.emit(hrEvents.leaveBalanceChanged, {
1358
+ workspaceId: input.workspaceId,
1359
+ personId: input.personId,
1360
+ leaveTypeId: input.leaveTypeId,
1361
+ deltaMinutes: input.amountMinutes,
1362
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1363
+ await changed(input.workspaceId, 'leave_balance', input.personId, 'updated');
1364
+ return toLedgerEntry(row);
1365
+ }),
1366
+ requests: {
1367
+ list: scoped.leave.requests.list
1368
+ .use(cap('leave'))
1369
+ .use(requires('hr.leave.view'))
1370
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1371
+ const where = [eq(leaveRequests.workspaceId, input.workspaceId)];
1372
+ if (input.personId)
1373
+ where.push(eq(leaveRequests.personId, input.personId));
1374
+ else if (!context.principal.instanceAdmin) {
1375
+ // Without an explicit person, this is "my requests". Seeing everybody's by default
1376
+ // would leak the whole company's absences to any member with hr.leave.view.
1377
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1378
+ where.push(me ? eq(leaveRequests.personId, me.id) : sql `false`);
1379
+ }
1380
+ if (input.status?.length)
1381
+ where.push(inArray(leaveRequests.status, input.status));
1382
+ if (input.from)
1383
+ where.push(gte(leaveRequests.endsOn, input.from));
1384
+ if (input.to)
1385
+ where.push(lte(leaveRequests.startsOn, input.to));
1386
+ const rows = await tx
1387
+ .select()
1388
+ .from(leaveRequests)
1389
+ .where(and(...where))
1390
+ .orderBy(desc(leaveRequests.startsOn))
1391
+ .limit(input.limit);
1392
+ return { items: rows.map(toLeaveRequest), nextCursor: null };
1393
+ })),
1394
+ get: scoped.leave.requests.get
1395
+ .use(cap('leave'))
1396
+ .use(requires('hr.leave.view'))
1397
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => toLeaveRequest(await loadRequest(tx, input.workspaceId, input.requestId)))),
1398
+ simulate: scoped.leave.requests.simulate
1399
+ .use(cap('leave'))
1400
+ .use(requires('hr.leave.request'))
1401
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1402
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1403
+ return simulate(tx, input.workspaceId, personId, input);
1404
+ })),
1405
+ create: scoped.leave.requests.create
1406
+ .use(cap('leave'))
1407
+ .use(requires('hr.leave.request'))
1408
+ .handler(async ({ input, context }) => {
1409
+ const result = await db.withWorkspace(input.workspaceId, async (tx) => {
1410
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1411
+ // Everything that spends balance takes the cursor lock first, inside this
1412
+ // transaction. Two overlapping requests for the last day cannot both read "enough".
1413
+ const year = yearOf(input.startsOn);
1414
+ await ledger.lockAndRead(tx, input.workspaceId, personId, input.leaveTypeId, year);
1415
+ const sim = await simulate(tx, input.workspaceId, personId, input);
1416
+ if (sim.blockers.length)
1417
+ throw KernError.conflict(sim.blockers[0].message, `hr.leave.${sim.blockers[0].code}`);
1418
+ const [request] = await tx
1419
+ .insert(leaveRequests)
1420
+ .values({
1421
+ id: uuidv7(),
1422
+ workspaceId: input.workspaceId,
1423
+ personId,
1424
+ leaveTypeId: input.leaveTypeId,
1425
+ startsOn: input.startsOn,
1426
+ endsOn: input.endsOn,
1427
+ startPart: input.startPart,
1428
+ endPart: input.endPart,
1429
+ hours: input.hours === null || input.hours === undefined ? null : String(input.hours),
1430
+ workingDays: String(sim.workingDays),
1431
+ minutes: sim.minutes,
1432
+ status: 'pending',
1433
+ reason: input.reason ?? null,
1434
+ documentFileId: input.documentFileId ?? null,
1435
+ idempotencyKey: input.idempotencyKey ?? null,
1436
+ })
1437
+ .returning();
1438
+ // The exploded days are what the partial unique index guards, so this insert is what
1439
+ // actually refuses a double booking — before any approval happens.
1440
+ await tx.insert(leaveRequestDays).values(sim.days.map((d) => ({
1441
+ id: uuidv7(),
1442
+ workspaceId: input.workspaceId,
1443
+ requestId: request.id,
1444
+ personId,
1445
+ date: d.date,
1446
+ fraction: String(d.fraction),
1447
+ counted: d.counted,
1448
+ status: 'pending',
1449
+ })));
1450
+ const raised = await approvals.raise(tx, input.workspaceId, {
1451
+ subjectType: 'leave',
1452
+ subjectId: request.id,
1453
+ summary: `${sim.workingDays} day(s) from ${input.startsOn}`,
1454
+ requesterPersonId: personId,
1455
+ requestedBy: context.principal.userId ?? null,
1456
+ on: input.startsOn,
1457
+ });
1458
+ await tx
1459
+ .update(leaveRequests)
1460
+ .set({ approvalRequestId: raised.request.id })
1461
+ .where(eq(leaveRequests.id, request.id));
1462
+ // A chain that resolves to nobody approves immediately — a one-person company has no
1463
+ // manager and still has to be able to book time off.
1464
+ if (raised.autoApproved)
1465
+ await applyApproval(tx, input.workspaceId, request.id, context.principal.userId ?? null);
1466
+ const [fresh] = await tx.select().from(leaveRequests).where(eq(leaveRequests.id, request.id));
1467
+ return { request: fresh, approvers: raised.firstStepApprovers, personId };
1468
+ });
1469
+ await kernel.emit(hrEvents.leaveRequested, {
1470
+ requestId: result.request.id,
1471
+ workspaceId: input.workspaceId,
1472
+ personId: result.personId,
1473
+ startsOn: input.startsOn,
1474
+ endsOn: input.endsOn,
1475
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1476
+ await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
1477
+ return toLeaveRequest(result.request);
1478
+ }),
1479
+ cancel: scoped.leave.requests.cancel
1480
+ .use(cap('leave'))
1481
+ .use(requires('hr.leave.request'))
1482
+ .handler(async ({ input, context }) => {
1483
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1484
+ const request = await loadRequest(tx, input.workspaceId, input.requestId);
1485
+ if (request.status === 'cancelled' || request.status === 'withdrawn')
1486
+ throw KernError.conflict('That request is already cancelled');
1487
+ const year = yearOf(request.startsOn);
1488
+ await ledger.lockAndRead(tx, input.workspaceId, request.personId, request.leaveTypeId, year);
1489
+ // Approved leave is *reversed*, not deleted. "She booked it and cancelled" and "she
1490
+ // never booked it" are different facts, and only one of them is true.
1491
+ if (request.status === 'approved')
1492
+ for (const entry of await ledger.entriesFor(tx, input.workspaceId, request.id))
1493
+ if (entry.kind === 'consumption')
1494
+ await ledger.reverse(tx, input.workspaceId, entry.id, input.reason ?? 'Leave cancelled', context.principal.userId ?? null, todayIso());
1495
+ await approvals.cancel(tx, input.workspaceId, 'leave', request.id);
1496
+ const next = request.status === 'approved' ? 'withdrawn' : 'cancelled';
1497
+ await tx
1498
+ .update(leaveRequestDays)
1499
+ .set({ status: next })
1500
+ .where(eq(leaveRequestDays.requestId, request.id));
1501
+ const [updated] = await tx
1502
+ .update(leaveRequests)
1503
+ .set({ status: next, decidedAt: new Date(), updatedAt: new Date() })
1504
+ .where(eq(leaveRequests.id, request.id))
1505
+ .returning();
1506
+ return updated;
1507
+ });
1508
+ await kernel.emit(hrEvents.leaveDecided, {
1509
+ requestId: row.id,
1510
+ workspaceId: input.workspaceId,
1511
+ personId: row.personId,
1512
+ status: row.status,
1513
+ startsOn: row.startsOn,
1514
+ endsOn: row.endsOn,
1515
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1516
+ await changed(input.workspaceId, 'leave_request', row.id, 'updated');
1517
+ return toLeaveRequest(row);
1518
+ }),
1519
+ },
1520
+ team: {
1521
+ calendar: scoped.leave.team.calendar
1522
+ .use(cap('leave'))
1523
+ .use(requires('hr.leave.view_team'))
1524
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1525
+ const rows = await tx
1526
+ .select()
1527
+ .from(leaveRequests)
1528
+ .where(and(eq(leaveRequests.workspaceId, input.workspaceId), inArray(leaveRequests.status, ['pending', 'approved']), lte(leaveRequests.startsOn, input.to), gte(leaveRequests.endsOn, input.from)));
1529
+ if (!rows.length)
1530
+ return [];
1531
+ const personIds = [...new Set(rows.map((r) => r.personId))];
1532
+ const persons = await tx
1533
+ .select({ id: people.id, displayName: people.displayName })
1534
+ .from(people)
1535
+ .where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, personIds)));
1536
+ const nameById = new Map(persons.map((p) => [p.id, p.displayName]));
1537
+ const types = await tx
1538
+ .select()
1539
+ .from(leaveTypes)
1540
+ .where(eq(leaveTypes.workspaceId, input.workspaceId));
1541
+ const typeById = new Map(types.map((t) => [t.id, t]));
1542
+ // Most companies want the team to know somebody is away without knowing it is sick
1543
+ // leave, so the type is named only for somebody who may read the ledger.
1544
+ const maySeeType = await kernel.authz.can(context.principal, 'hr.leave.view_ledger', {
1545
+ kind: 'workspace',
1546
+ id: input.workspaceId,
1547
+ workspaceId: input.workspaceId,
1548
+ });
1549
+ let filtered = rows;
1550
+ if (input.officeId) {
1551
+ const here = await tx
1552
+ .select({ personId: officeAssignments.personId })
1553
+ .from(officeAssignments)
1554
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
1555
+ const ids = new Set(here.map((h) => h.personId));
1556
+ filtered = filtered.filter((r) => ids.has(r.personId));
1557
+ }
1558
+ if (input.orgUnitId) {
1559
+ const ids = new Set(await unitMemberIds(tx, input.workspaceId, input.orgUnitId, true));
1560
+ filtered = filtered.filter((r) => ids.has(r.personId));
1561
+ }
1562
+ return filtered.map((r) => {
1563
+ const type = typeById.get(r.leaveTypeId);
1564
+ return {
1565
+ personId: r.personId,
1566
+ displayName: nameById.get(r.personId) ?? 'Unknown',
1567
+ requestId: r.id,
1568
+ startsOn: r.startsOn,
1569
+ endsOn: r.endsOn,
1570
+ status: r.status,
1571
+ leaveTypeName: maySeeType ? (type?.name ?? null) : null,
1572
+ color: type?.color ?? null,
1573
+ };
1574
+ });
1575
+ })),
1576
+ },
1577
+ },
1578
+ // ================================================================= approvals
1579
+ approvals: {
1580
+ /**
1581
+ * Everything waiting on the caller. No permission: an inbox of what *you* must decide is
1582
+ * yours by definition, and the engine only lists steps you are named on.
1583
+ */
1584
+ inbox: scoped.approvals.inbox.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1585
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1586
+ if (!me)
1587
+ return { items: [], nextCursor: null };
1588
+ const rows = await approvals.inboxFor(tx, input.workspaceId, me.id, input.includeDecided, input.limit);
1589
+ const items = [];
1590
+ for (const r of rows)
1591
+ items.push(await hydrateApproval(tx, r));
1592
+ return { items, nextCursor: null };
1593
+ })),
1594
+ get: scoped.approvals.get.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1595
+ const [row] = await tx
1596
+ .select()
1597
+ .from(approvalRequests)
1598
+ .where(and(eq(approvalRequests.workspaceId, input.workspaceId), eq(approvalRequests.id, input.requestId)))
1599
+ .limit(1);
1600
+ if (!row)
1601
+ throw KernError.notFound('Approval request');
1602
+ return hydrateApproval(tx, row);
1603
+ })),
1604
+ decide: scoped.approvals.decide.handler(async ({ input, context }) => {
1605
+ const outcome = await db.withWorkspace(input.workspaceId, async (tx) => {
1606
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1607
+ if (!me)
1608
+ throw KernError.forbidden('You have no employee record in this workspace');
1609
+ const result = await approvals.decide(tx, input.workspaceId, input.requestId, me.id, input.decision, input.comment ?? null, input.onBehalfOfId ?? null);
1610
+ // The approval engine knows nothing about leave. Applying the decision to the subject is
1611
+ // the caller's job, which is what keeps the engine reusable for regularization and
1612
+ // overtime later.
1613
+ const request = result.request;
1614
+ if (request.subjectType === 'leave') {
1615
+ if (result.status === 'approved')
1616
+ await applyApproval(tx, input.workspaceId, request.subjectId, context.principal.userId ?? null);
1617
+ else if (result.status === 'rejected')
1618
+ await tx
1619
+ .update(leaveRequests)
1620
+ .set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
1621
+ .where(eq(leaveRequests.id, request.subjectId));
1622
+ }
1623
+ const [fresh] = await tx
1624
+ .select()
1625
+ .from(approvalRequests)
1626
+ .where(eq(approvalRequests.id, input.requestId));
1627
+ return { hydrated: await hydrateApproval(tx, fresh), request: fresh };
1628
+ });
1629
+ await kernel.emit(hrEvents.approvalDecided, {
1630
+ requestId: outcome.request.id,
1631
+ workspaceId: input.workspaceId,
1632
+ subjectType: outcome.request.subjectType,
1633
+ subjectId: outcome.request.subjectId,
1634
+ status: outcome.request.status,
1635
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1636
+ await changed(input.workspaceId, 'approval', outcome.request.id, 'updated');
1637
+ return outcome.hydrated;
1638
+ }),
1639
+ chains: {
1640
+ list: scoped.approvals.chains.list
1641
+ .use(cap('approvals'))
1642
+ .use(requires('hr.approval.manage'))
1643
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1644
+ const where = [
1645
+ eq(approvalChains.workspaceId, input.workspaceId),
1646
+ isNull(approvalChains.archivedAt),
1647
+ ];
1648
+ if (input.subjectType)
1649
+ where.push(eq(approvalChains.subjectType, input.subjectType));
1650
+ const rows = await tx
1651
+ .select()
1652
+ .from(approvalChains)
1653
+ .where(and(...where))
1654
+ .orderBy(asc(approvalChains.name));
1655
+ return rows.map(toChain);
1656
+ })),
1657
+ create: scoped.approvals.chains.create
1658
+ .use(cap('approvals'))
1659
+ .use(requires('hr.approval.manage'))
1660
+ .handler(async ({ input }) => {
1661
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1662
+ if (input.isDefault)
1663
+ await clearDefaultChain(tx, input.workspaceId, input.subjectType);
1664
+ const [created] = await tx
1665
+ .insert(approvalChains)
1666
+ .values({
1667
+ id: uuidv7(),
1668
+ workspaceId: input.workspaceId,
1669
+ name: input.name,
1670
+ subjectType: input.subjectType,
1671
+ spec: input.spec,
1672
+ isDefault: input.isDefault,
1673
+ })
1674
+ .returning();
1675
+ return created;
1676
+ });
1677
+ await changed(input.workspaceId, 'approval_chain', row.id, 'created');
1678
+ return toChain(row);
1679
+ }),
1680
+ update: scoped.approvals.chains.update
1681
+ .use(cap('approvals'))
1682
+ .use(requires('hr.approval.manage'))
1683
+ .handler(async ({ input }) => {
1684
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1685
+ const [existing] = await tx
1686
+ .select()
1687
+ .from(approvalChains)
1688
+ .where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId)))
1689
+ .limit(1);
1690
+ if (!existing)
1691
+ throw KernError.notFound('Approval chain');
1692
+ if (input.isDefault)
1693
+ await clearDefaultChain(tx, input.workspaceId, existing.subjectType);
1694
+ const set = { updatedAt: new Date() };
1695
+ if (input.name !== undefined)
1696
+ set.name = input.name;
1697
+ if (input.spec !== undefined)
1698
+ set.spec = input.spec;
1699
+ if (input.isDefault !== undefined)
1700
+ set.isDefault = input.isDefault;
1701
+ const [updated] = await tx
1702
+ .update(approvalChains)
1703
+ .set(set)
1704
+ .where(eq(approvalChains.id, input.chainId))
1705
+ .returning();
1706
+ return updated;
1707
+ });
1708
+ await changed(input.workspaceId, 'approval_chain', row.id, 'updated');
1709
+ return toChain(row);
1710
+ }),
1711
+ archive: scoped.approvals.chains.archive
1712
+ .use(cap('approvals'))
1713
+ .use(requires('hr.approval.manage'))
1714
+ .handler(async ({ input }) => {
1715
+ // In-flight requests carry their own snapshot of the chain, so archiving one cannot
1716
+ // strand an approval half-signed.
1717
+ await db.withWorkspace(input.workspaceId, (tx) => tx
1718
+ .update(approvalChains)
1719
+ .set({ archivedAt: new Date(), isDefault: false })
1720
+ .where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId))));
1721
+ await changed(input.workspaceId, 'approval_chain', input.chainId, 'deleted');
1722
+ return { ok: true };
1723
+ }),
1724
+ },
1725
+ delegations: scoped.approvals.delegations
1726
+ .use(cap('approvals'))
1727
+ .use(requires('hr.approval.delegate'))
1728
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1729
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1730
+ const rows = await tx
1731
+ .select()
1732
+ .from(delegations)
1733
+ .where(and(eq(delegations.workspaceId, input.workspaceId), or(eq(delegations.fromPersonId, personId), eq(delegations.toPersonId, personId))))
1734
+ .orderBy(desc(delegations.startsOn));
1735
+ return rows.map(toDelegation);
1736
+ })),
1737
+ delegate: scoped.approvals.delegate
1738
+ .use(cap('approvals'))
1739
+ .use(requires('hr.approval.delegate'))
1740
+ .handler(async ({ input, context }) => {
1741
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1742
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1743
+ if (!me)
1744
+ throw KernError.forbidden('You have no employee record in this workspace');
1745
+ if (me.id === input.toPersonId)
1746
+ throw KernError.badRequest('You cannot delegate your approvals to yourself.');
1747
+ if (input.endsOn < input.startsOn)
1748
+ throw KernError.badRequest('A delegation cannot end before it starts.');
1749
+ const [created] = await tx
1750
+ .insert(delegations)
1751
+ .values({
1752
+ id: uuidv7(),
1753
+ workspaceId: input.workspaceId,
1754
+ fromPersonId: me.id,
1755
+ toPersonId: input.toPersonId,
1756
+ subjectType: input.subjectType ?? null,
1757
+ startsOn: input.startsOn,
1758
+ endsOn: input.endsOn,
1759
+ reason: input.reason ?? null,
1760
+ })
1761
+ .returning();
1762
+ return created;
1763
+ });
1764
+ await changed(input.workspaceId, 'delegation', row.id, 'created');
1765
+ return toDelegation(row);
1766
+ }),
1767
+ revokeDelegation: scoped.approvals.revokeDelegation
1768
+ .use(cap('approvals'))
1769
+ .use(requires('hr.approval.delegate'))
1770
+ .handler(async ({ input, context }) => {
1771
+ await db.withWorkspace(input.workspaceId, async (tx) => {
1772
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1773
+ const [row] = await tx
1774
+ .select()
1775
+ .from(delegations)
1776
+ .where(and(eq(delegations.workspaceId, input.workspaceId), eq(delegations.id, input.delegationId)))
1777
+ .limit(1);
1778
+ if (!row)
1779
+ throw KernError.notFound('Delegation');
1780
+ // Only the person who gave it away may take it back — otherwise a delegate could quietly
1781
+ // extend their own authority by revoking the competition.
1782
+ if (row.fromPersonId !== me?.id)
1783
+ throw KernError.forbidden('Only the person who delegated may revoke it');
1784
+ await tx.delete(delegations).where(eq(delegations.id, row.id));
1785
+ });
1786
+ await changed(input.workspaceId, 'delegation', input.delegationId, 'deleted');
1787
+ return { ok: true };
1788
+ }),
1789
+ },
1223
1790
  // ================================================================= custom fields
1224
1791
  fields: {
1225
1792
  list: scoped.fields.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
@@ -1495,6 +2062,217 @@ export function implement_(kernel) {
1495
2062
  const parent = await parentPath(tx, workspaceId, parentId);
1496
2063
  return parent ? `${parent}.${label}` : label;
1497
2064
  }
2065
+ /**
2066
+ * The person a call is about: the one named, or the caller.
2067
+ *
2068
+ * Reading somebody else's balance needs `hr.leave.view_team`; reading your own needs nothing
2069
+ * beyond being an employee. Collapsing those into one permission would either hide your own
2070
+ * balance from you or show you everybody's.
2071
+ */
2072
+ async function personFor(tx, workspaceId, context, personId) {
2073
+ const me = await svc.byUserId(tx, workspaceId, context.principal.userId ?? '');
2074
+ if (!personId) {
2075
+ if (!me)
2076
+ throw KernError.notFound('Your employee record');
2077
+ return me.id;
2078
+ }
2079
+ if (me && me.id === personId)
2080
+ return personId;
2081
+ await kernel.authz.require(context.principal, 'hr.leave.view_team', {
2082
+ kind: 'workspace',
2083
+ id: workspaceId,
2084
+ workspaceId,
2085
+ });
2086
+ return personId;
2087
+ }
2088
+ async function loadRequest(tx, workspaceId, requestId) {
2089
+ const [row] = await tx
2090
+ .select()
2091
+ .from(leaveRequests)
2092
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
2093
+ .limit(1);
2094
+ if (!row)
2095
+ throw KernError.notFound('Leave request');
2096
+ return row;
2097
+ }
2098
+ /**
2099
+ * What a request would cost, and every reason it would be refused.
2100
+ *
2101
+ * Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
2102
+ * submission is a preview that eventually lies. The blockers are returned rather than thrown here
2103
+ * so the screen can show all of them at once instead of one per round trip.
2104
+ */
2105
+ async function simulate(tx, workspaceId, personId, input) {
2106
+ const blockers = [];
2107
+ if (input.endsOn < input.startsOn)
2108
+ blockers.push({ code: 'range', message: 'The end date is before the start date.' });
2109
+ const [type] = await tx
2110
+ .select()
2111
+ .from(leaveTypes)
2112
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
2113
+ .limit(1);
2114
+ if (!type)
2115
+ throw KernError.notFound('Leave type');
2116
+ if (type.archivedAt)
2117
+ blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
2118
+ const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
2119
+ const calendarDaysInRange = resolution.calendarId
2120
+ ? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
2121
+ : [];
2122
+ const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
2123
+ ? calendarDaysInRange.map((d) => ({
2124
+ date: d.date,
2125
+ name: d.name,
2126
+ workingFraction: d.workingFraction,
2127
+ }))
2128
+ : []);
2129
+ // Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
2130
+ // holiday still costs nothing rather than costing half of nothing.
2131
+ const days = results.map((r) => {
2132
+ let fraction = r.fraction;
2133
+ if (r.date === input.startsOn && input.startPart === 'afternoon')
2134
+ fraction = Math.min(fraction, 0.5);
2135
+ if (r.date === input.endsOn && input.endPart === 'morning')
2136
+ fraction = Math.min(fraction, 0.5);
2137
+ return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
2138
+ });
2139
+ const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
2140
+ const minutes = type.unit === 'hour' && input.hours
2141
+ ? Math.round(input.hours * 60)
2142
+ : Math.round(workingDaysTotal * MINUTES_PER_DAY);
2143
+ if (minutes <= 0)
2144
+ blockers.push({
2145
+ code: 'empty',
2146
+ message: 'That range contains no working days.',
2147
+ });
2148
+ const year = yearOf(input.startsOn);
2149
+ const balances = await ledger.balances(tx, workspaceId, personId, year);
2150
+ const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
2151
+ const before = balance?.availableMinutes ?? 0;
2152
+ const after = before - minutes;
2153
+ if (after < 0 && !type.allowNegative)
2154
+ blockers.push({
2155
+ code: 'insufficient',
2156
+ message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
2157
+ });
2158
+ if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
2159
+ blockers.push({
2160
+ code: 'below_floor',
2161
+ message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
2162
+ });
2163
+ // Overlap is refused by a unique index as well; checking here turns a constraint violation into
2164
+ // a sentence naming the dates.
2165
+ const counted = days.filter((d) => d.counted).map((d) => d.date);
2166
+ if (counted.length) {
2167
+ const clash = await tx
2168
+ .select({ date: leaveRequestDays.date })
2169
+ .from(leaveRequestDays)
2170
+ .where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
2171
+ .limit(3);
2172
+ if (clash.length)
2173
+ blockers.push({
2174
+ code: 'overlap',
2175
+ message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
2176
+ });
2177
+ }
2178
+ if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
2179
+ blockers.push({
2180
+ code: 'document_required',
2181
+ message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
2182
+ });
2183
+ return {
2184
+ workingDays: workingDaysTotal,
2185
+ minutes,
2186
+ days,
2187
+ balanceBeforeMinutes: before,
2188
+ balanceAfterMinutes: after,
2189
+ blockers,
2190
+ };
2191
+ }
2192
+ /**
2193
+ * Turn an approved request into a ledger consumption.
2194
+ *
2195
+ * The working days are **recomputed here** rather than trusted from submission time: a holiday
2196
+ * can be added to the calendar between asking and approving, and the number that costs somebody
2197
+ * balance should be the one that was true when it was granted.
2198
+ */
2199
+ async function applyApproval(tx, workspaceId, leaveRequestId, actorId) {
2200
+ const request = await loadRequest(tx, workspaceId, leaveRequestId);
2201
+ if (request.status === 'approved')
2202
+ return;
2203
+ const sim = await simulate(tx, workspaceId, request.personId, {
2204
+ leaveTypeId: request.leaveTypeId,
2205
+ startsOn: request.startsOn,
2206
+ endsOn: request.endsOn,
2207
+ startPart: request.startPart,
2208
+ endPart: request.endPart,
2209
+ hours: request.hours === null ? null : Number.parseFloat(request.hours),
2210
+ });
2211
+ await ledger.append(tx, workspaceId, {
2212
+ personId: request.personId,
2213
+ leaveTypeId: request.leaveTypeId,
2214
+ kind: 'consumption',
2215
+ amountMinutes: -sim.minutes,
2216
+ effectiveOn: request.startsOn,
2217
+ periodYear: yearOf(request.startsOn),
2218
+ requestId: request.id,
2219
+ reason: null,
2220
+ createdBy: actorId,
2221
+ });
2222
+ await tx
2223
+ .update(leaveRequestDays)
2224
+ .set({ status: 'approved' })
2225
+ .where(eq(leaveRequestDays.requestId, request.id));
2226
+ await tx
2227
+ .update(leaveRequests)
2228
+ .set({
2229
+ status: 'approved',
2230
+ minutes: sim.minutes,
2231
+ workingDays: String(sim.workingDays),
2232
+ decidedAt: new Date(),
2233
+ updatedAt: new Date(),
2234
+ })
2235
+ .where(eq(leaveRequests.id, request.id));
2236
+ }
2237
+ async function clearDefaultChain(tx, workspaceId, subjectType) {
2238
+ await tx
2239
+ .update(approvalChains)
2240
+ .set({ isDefault: false })
2241
+ .where(and(eq(approvalChains.workspaceId, workspaceId), eq(approvalChains.subjectType, subjectType), eq(approvalChains.isDefault, true)));
2242
+ }
2243
+ /** An approval request with its steps and decisions, which is the only useful shape. */
2244
+ async function hydrateApproval(tx, row) {
2245
+ const steps = await tx
2246
+ .select()
2247
+ .from(approvalSteps)
2248
+ .where(eq(approvalSteps.requestId, row.id))
2249
+ .orderBy(asc(approvalSteps.stepIndex));
2250
+ const stepIds = steps.map((s) => s.id);
2251
+ const decisions = stepIds.length
2252
+ ? await tx.select().from(approvalDecisions).where(inArray(approvalDecisions.stepId, stepIds))
2253
+ : [];
2254
+ return {
2255
+ ...row,
2256
+ subjectType: row.subjectType,
2257
+ status: row.status,
2258
+ requestedAt: row.requestedAt.toISOString(),
2259
+ decidedAt: row.decidedAt?.toISOString() ?? null,
2260
+ steps: steps.map((s) => ({
2261
+ ...s,
2262
+ mode: s.mode,
2263
+ status: s.status,
2264
+ dueAt: s.dueAt?.toISOString() ?? null,
2265
+ escalatedAt: s.escalatedAt?.toISOString() ?? null,
2266
+ decisions: decisions
2267
+ .filter((d) => d.stepId === s.id)
2268
+ .map((d) => ({
2269
+ ...d,
2270
+ decision: d.decision,
2271
+ at: d.at.toISOString(),
2272
+ })),
2273
+ })),
2274
+ };
2275
+ }
1498
2276
  async function emitCalendarChanged(workspaceId, calendarId, from, to, actorId) {
1499
2277
  await kernel.emit(hrEvents.calendarChanged, { calendarId, workspaceId, from, to }, { workspaceId, actorId });
1500
2278
  await changed(workspaceId, 'calendar', calendarId, 'updated');
@@ -1534,4 +2312,36 @@ const toResolvedDay = (r, fromCalendarId, fromCalendarName, overrides) => ({
1534
2312
  fromCalendarName,
1535
2313
  overrides,
1536
2314
  });
2315
+ const toLeaveType = (r) => ({
2316
+ ...r,
2317
+ unit: r.unit,
2318
+ archivedAt: r.archivedAt?.toISOString() ?? null,
2319
+ });
2320
+ const toLedgerEntry = (r) => ({
2321
+ ...r,
2322
+ kind: r.kind,
2323
+ createdAt: r.createdAt.toISOString(),
2324
+ });
2325
+ const toLeaveRequest = (r) => ({
2326
+ ...r,
2327
+ startPart: r.startPart,
2328
+ endPart: r.endPart,
2329
+ status: r.status,
2330
+ hours: r.hours === null ? null : Number.parseFloat(r.hours),
2331
+ workingDays: Number.parseFloat(r.workingDays),
2332
+ decidedAt: r.decidedAt?.toISOString() ?? null,
2333
+ createdAt: r.createdAt.toISOString(),
2334
+ updatedAt: r.updatedAt.toISOString(),
2335
+ });
2336
+ const toChain = (r) => ({
2337
+ ...r,
2338
+ subjectType: r.subjectType,
2339
+ spec: r.spec,
2340
+ archivedAt: r.archivedAt?.toISOString() ?? null,
2341
+ });
2342
+ const toDelegation = (r) => ({
2343
+ ...r,
2344
+ subjectType: (r.subjectType ?? null),
2345
+ createdAt: r.createdAt.toISOString(),
2346
+ });
1537
2347
  //# sourceMappingURL=router.js.map