@kernhq/module-hr 0.2.0 → 0.4.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 (82) 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/attendance.d.ts +240 -0
  6. package/dist/contract/attendance.d.ts.map +1 -0
  7. package/dist/contract/attendance.js +154 -0
  8. package/dist/contract/attendance.js.map +1 -0
  9. package/dist/contract/capabilities.d.ts +1 -1
  10. package/dist/contract/capabilities.d.ts.map +1 -1
  11. package/dist/contract/capabilities.js +80 -0
  12. package/dist/contract/capabilities.js.map +1 -1
  13. package/dist/contract/events.d.ts +60 -0
  14. package/dist/contract/events.d.ts.map +1 -1
  15. package/dist/contract/events.js +60 -0
  16. package/dist/contract/events.js.map +1 -1
  17. package/dist/contract/index.d.ts +3 -0
  18. package/dist/contract/index.d.ts.map +1 -1
  19. package/dist/contract/index.js +3 -0
  20. package/dist/contract/index.js.map +1 -1
  21. package/dist/contract/leave.d.ts +168 -0
  22. package/dist/contract/leave.d.ts.map +1 -0
  23. package/dist/contract/leave.js +150 -0
  24. package/dist/contract/leave.js.map +1 -0
  25. package/dist/contract/permissions.d.ts +100 -0
  26. package/dist/contract/permissions.d.ts.map +1 -1
  27. package/dist/contract/permissions.js +119 -0
  28. package/dist/contract/permissions.js.map +1 -1
  29. package/dist/contract/router.d.ts +2440 -0
  30. package/dist/contract/router.d.ts.map +1 -1
  31. package/dist/contract/router.js +393 -0
  32. package/dist/contract/router.js.map +1 -1
  33. package/dist/policy/time.d.ts +48 -0
  34. package/dist/policy/time.d.ts.map +1 -0
  35. package/dist/policy/time.js +144 -0
  36. package/dist/policy/time.js.map +1 -0
  37. package/dist/policy/working-time.d.ts +98 -0
  38. package/dist/policy/working-time.d.ts.map +1 -0
  39. package/dist/policy/working-time.js +190 -0
  40. package/dist/policy/working-time.js.map +1 -0
  41. package/dist/server/index.d.ts.map +1 -1
  42. package/dist/server/index.js +2 -0
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/jobs.d.ts +16 -0
  45. package/dist/server/jobs.d.ts.map +1 -0
  46. package/dist/server/jobs.js +166 -0
  47. package/dist/server/jobs.js.map +1 -0
  48. package/dist/server/router.d.ts +2731 -58
  49. package/dist/server/router.d.ts.map +1 -1
  50. package/dist/server/router.js +1306 -3
  51. package/dist/server/router.js.map +1 -1
  52. package/dist/server/schema.d.ts +3344 -1
  53. package/dist/server/schema.d.ts.map +1 -1
  54. package/dist/server/schema.js +325 -0
  55. package/dist/server/schema.js.map +1 -1
  56. package/dist/server/services/approvals.d.ts +163 -0
  57. package/dist/server/services/approvals.d.ts.map +1 -0
  58. package/dist/server/services/approvals.js +325 -0
  59. package/dist/server/services/approvals.js.map +1 -0
  60. package/dist/server/services/attendance.d.ts +150 -0
  61. package/dist/server/services/attendance.d.ts.map +1 -0
  62. package/dist/server/services/attendance.js +239 -0
  63. package/dist/server/services/attendance.js.map +1 -0
  64. package/dist/server/services/ledger.d.ts +135 -0
  65. package/dist/server/services/ledger.d.ts.map +1 -0
  66. package/dist/server/services/ledger.js +178 -0
  67. package/dist/server/services/ledger.js.map +1 -0
  68. package/dist/server/services/people.d.ts +3 -3
  69. package/migrations/0002_leave.sql +237 -0
  70. package/migrations/0003_attendance.sql +206 -0
  71. package/migrations/meta/0002_snapshot.json +3009 -0
  72. package/migrations/meta/0003_snapshot.json +3681 -0
  73. package/migrations/meta/_journal.json +14 -0
  74. package/package.json +1 -1
  75. package/src/contract/approvals.ts +149 -0
  76. package/src/contract/attendance.ts +180 -0
  77. package/src/contract/capabilities.ts +80 -0
  78. package/src/contract/events.ts +81 -0
  79. package/src/contract/index.ts +3 -0
  80. package/src/contract/leave.ts +171 -0
  81. package/src/contract/permissions.ts +122 -0
  82. package/src/contract/router.ts +481 -0
@@ -1,11 +1,15 @@
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
+ import { businessDateFor } from '../policy/working-time.js';
6
7
  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';
8
+ import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, attendanceDays, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, personDocuments, personHistory, positions, punches, regularizations, scheduleAssignments, schedules, } from './schema.js';
9
+ import { ApprovalService } from './services/approvals.js';
10
+ import { AttendanceService } from './services/attendance.js';
11
+ import { inForceOn, todayIn, todayIso } from './services/db.js';
12
+ import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
9
13
  import { PeopleService } from './services/people.js';
10
14
  import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
11
15
  const os = implement(hrContract).$context();
@@ -26,6 +30,9 @@ export function implement_(kernel) {
26
30
  const cap = (id) => requiresCapability(MODULE_ID, id);
27
31
  const resolve = new ResolveService();
28
32
  const svc = new PeopleService(kernel);
33
+ const ledger = new LedgerService();
34
+ const approvals = new ApprovalService(kernel);
35
+ const attendance = new AttendanceService();
29
36
  const db = kernel.database;
30
37
  const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
31
38
  const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
@@ -1220,6 +1227,906 @@ export function implement_(kernel) {
1220
1227
  return { ok: true };
1221
1228
  }),
1222
1229
  },
1230
+ // ================================================================= attendance
1231
+ attendance: {
1232
+ state: scoped.attendance.state
1233
+ .use(cap('attendance'))
1234
+ .use(requires('hr.attendance.view'))
1235
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1236
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1237
+ const { timezone, businessDate, schedule } = await clockContext(tx, input.workspaceId, personId);
1238
+ const rows = await attendance.punchesOn(tx, input.workspaceId, personId, businessDate);
1239
+ let open = null;
1240
+ let onBreak = false;
1241
+ let workedMs = 0;
1242
+ let breakOpen = null;
1243
+ for (const r of rows) {
1244
+ if (r.direction === 'in')
1245
+ open = r.at;
1246
+ else if (r.direction === 'out' && open) {
1247
+ workedMs += r.at.getTime() - open.getTime();
1248
+ open = null;
1249
+ }
1250
+ else if (r.direction === 'break_start') {
1251
+ breakOpen = r.at;
1252
+ onBreak = true;
1253
+ }
1254
+ else if (r.direction === 'break_end' && breakOpen) {
1255
+ workedMs -= r.at.getTime() - breakOpen.getTime();
1256
+ breakOpen = null;
1257
+ onBreak = false;
1258
+ }
1259
+ }
1260
+ // An open span counts up to now, so the widget shows time accruing rather than freezing
1261
+ // at the last completed pair.
1262
+ if (open)
1263
+ workedMs += Date.now() - open.getTime();
1264
+ if (breakOpen)
1265
+ workedMs -= Date.now() - breakOpen.getTime();
1266
+ void schedule;
1267
+ return {
1268
+ personId,
1269
+ businessDate,
1270
+ clockedIn: open !== null,
1271
+ onBreak,
1272
+ since: (open ?? breakOpen)?.toISOString() ?? null,
1273
+ workedMinutesToday: Math.max(0, Math.round(workedMs / 60000)),
1274
+ timezone,
1275
+ };
1276
+ })),
1277
+ clockIn: scoped.attendance.clockIn
1278
+ .use(cap('attendance'))
1279
+ .use(requires('hr.attendance.punch'))
1280
+ .handler(({ input, context }) => punch(input, context, 'in')),
1281
+ clockOut: scoped.attendance.clockOut
1282
+ .use(cap('attendance'))
1283
+ .use(requires('hr.attendance.punch'))
1284
+ .handler(({ input, context }) => punch(input, context, 'out')),
1285
+ breakStart: scoped.attendance.breakStart
1286
+ .use(cap('attendance'))
1287
+ .use(requires('hr.attendance.punch'))
1288
+ .handler(({ input, context }) => punch(input, context, 'break_start')),
1289
+ breakEnd: scoped.attendance.breakEnd
1290
+ .use(cap('attendance'))
1291
+ .use(requires('hr.attendance.punch'))
1292
+ .handler(({ input, context }) => punch(input, context, 'break_end')),
1293
+ punches: {
1294
+ list: scoped.attendance.punches.list
1295
+ .use(cap('attendance'))
1296
+ .use(requires('hr.attendance.view'))
1297
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1298
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1299
+ const where = [
1300
+ eq(punches.workspaceId, input.workspaceId),
1301
+ eq(punches.personId, personId),
1302
+ gte(punches.businessDate, input.from),
1303
+ lte(punches.businessDate, input.to),
1304
+ ];
1305
+ if (!input.includeVoided)
1306
+ where.push(isNull(punches.voidedByPunchId));
1307
+ const rows = await tx
1308
+ .select()
1309
+ .from(punches)
1310
+ .where(and(...where))
1311
+ .orderBy(asc(punches.at))
1312
+ .limit(input.limit);
1313
+ return { items: rows.map(toPunch), nextCursor: null };
1314
+ })),
1315
+ void: scoped.attendance.punches.void
1316
+ .use(cap('attendance'))
1317
+ .use(requires('hr.attendance.manage'))
1318
+ .handler(async ({ input, context }) => {
1319
+ const affected = await db.withWorkspace(input.workspaceId, async (tx) => {
1320
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1321
+ const { original } = await attendance.voidPunch(tx, input.workspaceId, input.punchId, input.reason, me?.id ?? null);
1322
+ // The day is derived, so voiding a punch means the sheet is stale until it is rebuilt.
1323
+ const { timezone, schedule } = await clockContext(tx, input.workspaceId, original.personId);
1324
+ await attendance.recomputeDay(tx, input.workspaceId, original.personId, original.businessDate, timezone, schedule);
1325
+ return original;
1326
+ });
1327
+ await changed(input.workspaceId, 'attendance_day', affected.personId, 'updated');
1328
+ return { ok: true };
1329
+ }),
1330
+ },
1331
+ days: {
1332
+ list: scoped.attendance.days.list
1333
+ .use(cap('attendance'))
1334
+ .use(requires('hr.attendance.view'))
1335
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1336
+ const where = [
1337
+ eq(attendanceDays.workspaceId, input.workspaceId),
1338
+ gte(attendanceDays.businessDate, input.from),
1339
+ lte(attendanceDays.businessDate, input.to),
1340
+ ];
1341
+ if (input.officeId) {
1342
+ const here = await tx
1343
+ .select({ personId: officeAssignments.personId })
1344
+ .from(officeAssignments)
1345
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
1346
+ // Reading a whole office needs the team permission; reading yourself does not.
1347
+ await kernel.authz.require(context.principal, 'hr.attendance.view_team', {
1348
+ kind: 'workspace',
1349
+ id: input.workspaceId,
1350
+ workspaceId: input.workspaceId,
1351
+ });
1352
+ where.push(here.length
1353
+ ? inArray(attendanceDays.personId, here.map((h) => h.personId))
1354
+ : sql `false`);
1355
+ }
1356
+ else {
1357
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1358
+ where.push(eq(attendanceDays.personId, personId));
1359
+ }
1360
+ const rows = await tx
1361
+ .select()
1362
+ .from(attendanceDays)
1363
+ .where(and(...where))
1364
+ .orderBy(asc(attendanceDays.businessDate))
1365
+ .limit(input.limit);
1366
+ return { items: rows.map(toAttendanceDay), nextCursor: null };
1367
+ })),
1368
+ recompute: scoped.attendance.days.recompute
1369
+ .use(cap('attendance'))
1370
+ .use(requires('hr.attendance.manage'))
1371
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1372
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1373
+ const { timezone, schedule } = await clockContext(tx, input.workspaceId, personId);
1374
+ const dates = await attendance.datesWithPunches(tx, input.workspaceId, personId, input.from, input.to);
1375
+ let recomputed = 0;
1376
+ const skippedLocked = [];
1377
+ for (const date of dates) {
1378
+ const r = await attendance.recomputeDay(tx, input.workspaceId, personId, date, timezone, schedule);
1379
+ // Named rather than silently skipped: a recomputation that quietly declines to touch
1380
+ // a closed month looks identical to one that had nothing to do.
1381
+ if (r.locked)
1382
+ skippedLocked.push(date);
1383
+ else
1384
+ recomputed++;
1385
+ }
1386
+ return { recomputed, skippedLocked };
1387
+ })),
1388
+ },
1389
+ schedules: {
1390
+ list: scoped.attendance.schedules.list
1391
+ .use(cap('attendance'))
1392
+ .use(requires('hr.attendance.view'))
1393
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1394
+ const where = [eq(schedules.workspaceId, input.workspaceId)];
1395
+ if (!input.includeArchived)
1396
+ where.push(isNull(schedules.archivedAt));
1397
+ const rows = await tx
1398
+ .select()
1399
+ .from(schedules)
1400
+ .where(and(...where))
1401
+ .orderBy(asc(schedules.name));
1402
+ return rows.map(toSchedule);
1403
+ })),
1404
+ create: scoped.attendance.schedules.create
1405
+ .use(cap('attendance'))
1406
+ .use(requires('hr.attendance.manage'))
1407
+ .handler(async ({ input }) => {
1408
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1409
+ const [created] = await tx
1410
+ .insert(schedules)
1411
+ .values({
1412
+ id: uuidv7(),
1413
+ workspaceId: input.workspaceId,
1414
+ name: input.name,
1415
+ kind: input.kind,
1416
+ week: input.week,
1417
+ tzMode: input.tzMode,
1418
+ tz: input.tz ?? null,
1419
+ graceInMinutes: input.graceInMinutes,
1420
+ graceOutMinutes: input.graceOutMinutes,
1421
+ roundingStepMinutes: input.roundingStepMinutes,
1422
+ roundingDirection: input.roundingDirection,
1423
+ autoClockOutAfterMinutes: input.autoClockOutAfterMinutes ?? null,
1424
+ })
1425
+ .returning();
1426
+ return created;
1427
+ });
1428
+ await changed(input.workspaceId, 'schedule', row.id, 'created');
1429
+ return toSchedule(row);
1430
+ }),
1431
+ update: scoped.attendance.schedules.update
1432
+ .use(cap('attendance'))
1433
+ .use(requires('hr.attendance.manage'))
1434
+ .handler(async ({ input }) => {
1435
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1436
+ const { workspaceId, scheduleId, ...patch } = input;
1437
+ const set = { updatedAt: new Date() };
1438
+ for (const [k, v] of Object.entries(patch))
1439
+ if (v !== undefined)
1440
+ set[k] = v;
1441
+ const [updated] = await tx
1442
+ .update(schedules)
1443
+ .set(set)
1444
+ .where(and(eq(schedules.workspaceId, workspaceId), eq(schedules.id, scheduleId)))
1445
+ .returning();
1446
+ if (!updated)
1447
+ throw KernError.notFound('Schedule');
1448
+ return updated;
1449
+ });
1450
+ await changed(input.workspaceId, 'schedule', row.id, 'updated');
1451
+ return toSchedule(row);
1452
+ }),
1453
+ archive: scoped.attendance.schedules.archive
1454
+ .use(cap('attendance'))
1455
+ .use(requires('hr.attendance.manage'))
1456
+ .handler(async ({ input }) => {
1457
+ await db.withWorkspace(input.workspaceId, (tx) => tx
1458
+ .update(schedules)
1459
+ .set({ archivedAt: new Date() })
1460
+ .where(and(eq(schedules.workspaceId, input.workspaceId), eq(schedules.id, input.scheduleId))));
1461
+ await changed(input.workspaceId, 'schedule', input.scheduleId, 'deleted');
1462
+ return { ok: true };
1463
+ }),
1464
+ assign: scoped.attendance.schedules.assign
1465
+ .use(cap('attendance'))
1466
+ .use(requires('hr.attendance.manage'))
1467
+ .handler(async ({ input }) => {
1468
+ const rows = await db.withWorkspace(input.workspaceId, async (tx) => {
1469
+ // Effective-dated like everything else here: the old assignment is closed the day
1470
+ // before, so "which schedule was she on in March" stays answerable.
1471
+ await tx
1472
+ .update(scheduleAssignments)
1473
+ .set({ effectiveTo: sql `${input.effectiveFrom}::date - 1` })
1474
+ .where(and(eq(scheduleAssignments.workspaceId, input.workspaceId), eq(scheduleAssignments.personId, input.personId), isNull(scheduleAssignments.effectiveTo)));
1475
+ await tx.insert(scheduleAssignments).values({
1476
+ id: uuidv7(),
1477
+ workspaceId: input.workspaceId,
1478
+ personId: input.personId,
1479
+ scheduleId: input.scheduleId,
1480
+ effectiveFrom: input.effectiveFrom,
1481
+ });
1482
+ return tx
1483
+ .select()
1484
+ .from(scheduleAssignments)
1485
+ .where(and(eq(scheduleAssignments.workspaceId, input.workspaceId), eq(scheduleAssignments.personId, input.personId)))
1486
+ .orderBy(desc(scheduleAssignments.effectiveFrom));
1487
+ });
1488
+ await changed(input.workspaceId, 'schedule', input.scheduleId, 'updated');
1489
+ return rows.map(toScheduleAssignment);
1490
+ }),
1491
+ },
1492
+ regularizations: {
1493
+ list: scoped.attendance.regularizations.list
1494
+ .use(cap('attendance'))
1495
+ .use(requires('hr.attendance.view'))
1496
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1497
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1498
+ const where = [
1499
+ eq(regularizations.workspaceId, input.workspaceId),
1500
+ eq(regularizations.personId, personId),
1501
+ ];
1502
+ if (input.status?.length)
1503
+ where.push(inArray(regularizations.status, input.status));
1504
+ const rows = await tx
1505
+ .select()
1506
+ .from(regularizations)
1507
+ .where(and(...where))
1508
+ .orderBy(desc(regularizations.businessDate))
1509
+ .limit(input.limit);
1510
+ return { items: rows.map(toRegularization), nextCursor: null };
1511
+ })),
1512
+ request: scoped.attendance.regularizations.request
1513
+ .use(cap('attendance'))
1514
+ .use(requires('hr.attendance.punch'))
1515
+ .handler(async ({ input, context }) => {
1516
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1517
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1518
+ const [created] = await tx
1519
+ .insert(regularizations)
1520
+ .values({
1521
+ id: uuidv7(),
1522
+ workspaceId: input.workspaceId,
1523
+ personId,
1524
+ businessDate: input.businessDate,
1525
+ punchId: input.punchId ?? null,
1526
+ proposed: input.proposed,
1527
+ reason: input.reason,
1528
+ status: 'pending',
1529
+ })
1530
+ .returning();
1531
+ // The same engine leave uses. That reuse is the reason approvals were built keyed by
1532
+ // subject type rather than bolted onto leave_requests.
1533
+ const raised = await approvals.raise(tx, input.workspaceId, {
1534
+ subjectType: 'regularization',
1535
+ subjectId: created.id,
1536
+ summary: `Correction for ${input.businessDate}`,
1537
+ requesterPersonId: personId,
1538
+ requestedBy: context.principal.userId ?? null,
1539
+ on: input.businessDate,
1540
+ });
1541
+ await tx
1542
+ .update(regularizations)
1543
+ .set({ approvalRequestId: raised.request.id })
1544
+ .where(eq(regularizations.id, created.id));
1545
+ if (raised.autoApproved)
1546
+ await applyRegularization(tx, input.workspaceId, created.id);
1547
+ const [fresh] = await tx
1548
+ .select()
1549
+ .from(regularizations)
1550
+ .where(eq(regularizations.id, created.id));
1551
+ return fresh;
1552
+ });
1553
+ await changed(input.workspaceId, 'regularization', row.id, 'created');
1554
+ return toRegularization(row);
1555
+ }),
1556
+ },
1557
+ },
1558
+ // ================================================================= leave
1559
+ leave: {
1560
+ types: {
1561
+ list: scoped.leave.types.list
1562
+ .use(cap('leave'))
1563
+ .use(requires('hr.leave.view'))
1564
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1565
+ const where = [eq(leaveTypes.workspaceId, input.workspaceId)];
1566
+ if (!input.includeArchived)
1567
+ where.push(isNull(leaveTypes.archivedAt));
1568
+ const rows = await tx
1569
+ .select()
1570
+ .from(leaveTypes)
1571
+ .where(and(...where))
1572
+ .orderBy(asc(leaveTypes.order), asc(leaveTypes.name));
1573
+ return rows.map(toLeaveType);
1574
+ })),
1575
+ create: scoped.leave.types.create
1576
+ .use(cap('leave'))
1577
+ .use(requires('hr.leave.manage'))
1578
+ .handler(async ({ input }) => {
1579
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1580
+ const [created] = await tx
1581
+ .insert(leaveTypes)
1582
+ .values({
1583
+ id: uuidv7(),
1584
+ workspaceId: input.workspaceId,
1585
+ key: input.key,
1586
+ name: input.name,
1587
+ paid: input.paid,
1588
+ unit: input.unit,
1589
+ color: input.color ?? null,
1590
+ icon: input.icon ?? null,
1591
+ requiresDocumentAfterDays: input.requiresDocumentAfterDays ?? null,
1592
+ countsWorkingDaysOnly: input.countsWorkingDaysOnly,
1593
+ allowNegative: input.allowNegative,
1594
+ maxNegativeMinutes: input.maxNegativeMinutes,
1595
+ })
1596
+ .returning();
1597
+ return created;
1598
+ });
1599
+ await changed(input.workspaceId, 'leave_type', row.id, 'created');
1600
+ return toLeaveType(row);
1601
+ }),
1602
+ update: scoped.leave.types.update
1603
+ .use(cap('leave'))
1604
+ .use(requires('hr.leave.manage'))
1605
+ .handler(async ({ input }) => {
1606
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1607
+ const { workspaceId, leaveTypeId, ...patch } = input;
1608
+ const set = { updatedAt: new Date() };
1609
+ for (const [k, v] of Object.entries(patch))
1610
+ if (v !== undefined)
1611
+ set[k] = v;
1612
+ const [updated] = await tx
1613
+ .update(leaveTypes)
1614
+ .set(set)
1615
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, leaveTypeId)))
1616
+ .returning();
1617
+ if (!updated)
1618
+ throw KernError.notFound('Leave type');
1619
+ return updated;
1620
+ });
1621
+ await changed(input.workspaceId, 'leave_type', row.id, 'updated');
1622
+ return toLeaveType(row);
1623
+ }),
1624
+ archive: scoped.leave.types.archive
1625
+ .use(cap('leave'))
1626
+ .use(requires('hr.leave.manage'))
1627
+ .handler(async ({ input }) => {
1628
+ // Archived, never deleted: the ledger points at it, and a balance whose type has
1629
+ // vanished is a number nobody can explain.
1630
+ await db.withWorkspace(input.workspaceId, (tx) => tx
1631
+ .update(leaveTypes)
1632
+ .set({ archivedAt: new Date() })
1633
+ .where(and(eq(leaveTypes.workspaceId, input.workspaceId), eq(leaveTypes.id, input.leaveTypeId))));
1634
+ await changed(input.workspaceId, 'leave_type', input.leaveTypeId, 'deleted');
1635
+ return { ok: true };
1636
+ }),
1637
+ },
1638
+ balance: {
1639
+ get: scoped.leave.balance.get
1640
+ .use(cap('leave'))
1641
+ .use(requires('hr.leave.view'))
1642
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1643
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1644
+ const year = input.periodYear ?? new Date().getUTCFullYear();
1645
+ return ledger.balances(tx, input.workspaceId, personId, year);
1646
+ })),
1647
+ },
1648
+ ledger: {
1649
+ list: scoped.leave.ledger.list
1650
+ .use(cap('leave'))
1651
+ .use(requires('hr.leave.view_ledger'))
1652
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1653
+ const where = [
1654
+ eq(leaveLedger.workspaceId, input.workspaceId),
1655
+ eq(leaveLedger.personId, input.personId),
1656
+ ];
1657
+ if (input.leaveTypeId)
1658
+ where.push(eq(leaveLedger.leaveTypeId, input.leaveTypeId));
1659
+ if (input.periodYear)
1660
+ where.push(eq(leaveLedger.periodYear, input.periodYear));
1661
+ const rows = await tx
1662
+ .select()
1663
+ .from(leaveLedger)
1664
+ .where(and(...where))
1665
+ .orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.createdAt))
1666
+ .limit(input.limit);
1667
+ return { items: rows.map(toLedgerEntry), nextCursor: null };
1668
+ })),
1669
+ },
1670
+ adjust: scoped.leave.adjust
1671
+ .use(cap('leave'))
1672
+ .use(requires('hr.leave.adjust'))
1673
+ .handler(async ({ input, context }) => {
1674
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1675
+ const year = yearOf(input.effectiveOn);
1676
+ await ledger.lockAndRead(tx, input.workspaceId, input.personId, input.leaveTypeId, year);
1677
+ return ledger.append(tx, input.workspaceId, {
1678
+ personId: input.personId,
1679
+ leaveTypeId: input.leaveTypeId,
1680
+ kind: input.kind,
1681
+ amountMinutes: input.amountMinutes,
1682
+ effectiveOn: input.effectiveOn,
1683
+ periodYear: year,
1684
+ reason: input.reason,
1685
+ createdBy: context.principal.userId ?? null,
1686
+ });
1687
+ });
1688
+ await kernel.emit(hrEvents.leaveBalanceChanged, {
1689
+ workspaceId: input.workspaceId,
1690
+ personId: input.personId,
1691
+ leaveTypeId: input.leaveTypeId,
1692
+ deltaMinutes: input.amountMinutes,
1693
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1694
+ await changed(input.workspaceId, 'leave_balance', input.personId, 'updated');
1695
+ return toLedgerEntry(row);
1696
+ }),
1697
+ requests: {
1698
+ list: scoped.leave.requests.list
1699
+ .use(cap('leave'))
1700
+ .use(requires('hr.leave.view'))
1701
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1702
+ const where = [eq(leaveRequests.workspaceId, input.workspaceId)];
1703
+ if (input.personId)
1704
+ where.push(eq(leaveRequests.personId, input.personId));
1705
+ else if (!context.principal.instanceAdmin) {
1706
+ // Without an explicit person, this is "my requests". Seeing everybody's by default
1707
+ // would leak the whole company's absences to any member with hr.leave.view.
1708
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1709
+ where.push(me ? eq(leaveRequests.personId, me.id) : sql `false`);
1710
+ }
1711
+ if (input.status?.length)
1712
+ where.push(inArray(leaveRequests.status, input.status));
1713
+ if (input.from)
1714
+ where.push(gte(leaveRequests.endsOn, input.from));
1715
+ if (input.to)
1716
+ where.push(lte(leaveRequests.startsOn, input.to));
1717
+ const rows = await tx
1718
+ .select()
1719
+ .from(leaveRequests)
1720
+ .where(and(...where))
1721
+ .orderBy(desc(leaveRequests.startsOn))
1722
+ .limit(input.limit);
1723
+ return { items: rows.map(toLeaveRequest), nextCursor: null };
1724
+ })),
1725
+ get: scoped.leave.requests.get
1726
+ .use(cap('leave'))
1727
+ .use(requires('hr.leave.view'))
1728
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => toLeaveRequest(await loadRequest(tx, input.workspaceId, input.requestId)))),
1729
+ simulate: scoped.leave.requests.simulate
1730
+ .use(cap('leave'))
1731
+ .use(requires('hr.leave.request'))
1732
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1733
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1734
+ return simulate(tx, input.workspaceId, personId, input);
1735
+ })),
1736
+ create: scoped.leave.requests.create
1737
+ .use(cap('leave'))
1738
+ .use(requires('hr.leave.request'))
1739
+ .handler(async ({ input, context }) => {
1740
+ const result = await db.withWorkspace(input.workspaceId, async (tx) => {
1741
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
1742
+ // Everything that spends balance takes the cursor lock first, inside this
1743
+ // transaction. Two overlapping requests for the last day cannot both read "enough".
1744
+ const year = yearOf(input.startsOn);
1745
+ await ledger.lockAndRead(tx, input.workspaceId, personId, input.leaveTypeId, year);
1746
+ const sim = await simulate(tx, input.workspaceId, personId, input);
1747
+ if (sim.blockers.length)
1748
+ throw KernError.conflict(sim.blockers[0].message, `hr.leave.${sim.blockers[0].code}`);
1749
+ const [request] = await tx
1750
+ .insert(leaveRequests)
1751
+ .values({
1752
+ id: uuidv7(),
1753
+ workspaceId: input.workspaceId,
1754
+ personId,
1755
+ leaveTypeId: input.leaveTypeId,
1756
+ startsOn: input.startsOn,
1757
+ endsOn: input.endsOn,
1758
+ startPart: input.startPart,
1759
+ endPart: input.endPart,
1760
+ hours: input.hours === null || input.hours === undefined ? null : String(input.hours),
1761
+ workingDays: String(sim.workingDays),
1762
+ minutes: sim.minutes,
1763
+ status: 'pending',
1764
+ reason: input.reason ?? null,
1765
+ documentFileId: input.documentFileId ?? null,
1766
+ idempotencyKey: input.idempotencyKey ?? null,
1767
+ })
1768
+ .returning();
1769
+ // The exploded days are what the partial unique index guards, so this insert is what
1770
+ // actually refuses a double booking — before any approval happens.
1771
+ await tx.insert(leaveRequestDays).values(sim.days.map((d) => ({
1772
+ id: uuidv7(),
1773
+ workspaceId: input.workspaceId,
1774
+ requestId: request.id,
1775
+ personId,
1776
+ date: d.date,
1777
+ fraction: String(d.fraction),
1778
+ counted: d.counted,
1779
+ status: 'pending',
1780
+ })));
1781
+ const raised = await approvals.raise(tx, input.workspaceId, {
1782
+ subjectType: 'leave',
1783
+ subjectId: request.id,
1784
+ summary: `${sim.workingDays} day(s) from ${input.startsOn}`,
1785
+ requesterPersonId: personId,
1786
+ requestedBy: context.principal.userId ?? null,
1787
+ on: input.startsOn,
1788
+ });
1789
+ await tx
1790
+ .update(leaveRequests)
1791
+ .set({ approvalRequestId: raised.request.id })
1792
+ .where(eq(leaveRequests.id, request.id));
1793
+ // A chain that resolves to nobody approves immediately — a one-person company has no
1794
+ // manager and still has to be able to book time off.
1795
+ if (raised.autoApproved)
1796
+ await applyApproval(tx, input.workspaceId, request.id, context.principal.userId ?? null);
1797
+ const [fresh] = await tx.select().from(leaveRequests).where(eq(leaveRequests.id, request.id));
1798
+ return { request: fresh, approvers: raised.firstStepApprovers, personId };
1799
+ });
1800
+ await kernel.emit(hrEvents.leaveRequested, {
1801
+ requestId: result.request.id,
1802
+ workspaceId: input.workspaceId,
1803
+ personId: result.personId,
1804
+ startsOn: input.startsOn,
1805
+ endsOn: input.endsOn,
1806
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1807
+ await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
1808
+ return toLeaveRequest(result.request);
1809
+ }),
1810
+ cancel: scoped.leave.requests.cancel
1811
+ .use(cap('leave'))
1812
+ .use(requires('hr.leave.request'))
1813
+ .handler(async ({ input, context }) => {
1814
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
1815
+ const request = await loadRequest(tx, input.workspaceId, input.requestId);
1816
+ if (request.status === 'cancelled' || request.status === 'withdrawn')
1817
+ throw KernError.conflict('That request is already cancelled');
1818
+ const year = yearOf(request.startsOn);
1819
+ await ledger.lockAndRead(tx, input.workspaceId, request.personId, request.leaveTypeId, year);
1820
+ // Approved leave is *reversed*, not deleted. "She booked it and cancelled" and "she
1821
+ // never booked it" are different facts, and only one of them is true.
1822
+ if (request.status === 'approved')
1823
+ for (const entry of await ledger.entriesFor(tx, input.workspaceId, request.id))
1824
+ if (entry.kind === 'consumption')
1825
+ await ledger.reverse(tx, input.workspaceId, entry.id, input.reason ?? 'Leave cancelled', context.principal.userId ?? null, todayIso());
1826
+ await approvals.cancel(tx, input.workspaceId, 'leave', request.id);
1827
+ const next = request.status === 'approved' ? 'withdrawn' : 'cancelled';
1828
+ await tx
1829
+ .update(leaveRequestDays)
1830
+ .set({ status: next })
1831
+ .where(eq(leaveRequestDays.requestId, request.id));
1832
+ const [updated] = await tx
1833
+ .update(leaveRequests)
1834
+ .set({ status: next, decidedAt: new Date(), updatedAt: new Date() })
1835
+ .where(eq(leaveRequests.id, request.id))
1836
+ .returning();
1837
+ return updated;
1838
+ });
1839
+ await kernel.emit(hrEvents.leaveDecided, {
1840
+ requestId: row.id,
1841
+ workspaceId: input.workspaceId,
1842
+ personId: row.personId,
1843
+ status: row.status,
1844
+ startsOn: row.startsOn,
1845
+ endsOn: row.endsOn,
1846
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1847
+ await changed(input.workspaceId, 'leave_request', row.id, 'updated');
1848
+ return toLeaveRequest(row);
1849
+ }),
1850
+ },
1851
+ team: {
1852
+ calendar: scoped.leave.team.calendar
1853
+ .use(cap('leave'))
1854
+ .use(requires('hr.leave.view_team'))
1855
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1856
+ const rows = await tx
1857
+ .select()
1858
+ .from(leaveRequests)
1859
+ .where(and(eq(leaveRequests.workspaceId, input.workspaceId), inArray(leaveRequests.status, ['pending', 'approved']), lte(leaveRequests.startsOn, input.to), gte(leaveRequests.endsOn, input.from)));
1860
+ if (!rows.length)
1861
+ return [];
1862
+ const personIds = [...new Set(rows.map((r) => r.personId))];
1863
+ const persons = await tx
1864
+ .select({ id: people.id, displayName: people.displayName })
1865
+ .from(people)
1866
+ .where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, personIds)));
1867
+ const nameById = new Map(persons.map((p) => [p.id, p.displayName]));
1868
+ const types = await tx
1869
+ .select()
1870
+ .from(leaveTypes)
1871
+ .where(eq(leaveTypes.workspaceId, input.workspaceId));
1872
+ const typeById = new Map(types.map((t) => [t.id, t]));
1873
+ // Most companies want the team to know somebody is away without knowing it is sick
1874
+ // leave, so the type is named only for somebody who may read the ledger.
1875
+ const maySeeType = await kernel.authz.can(context.principal, 'hr.leave.view_ledger', {
1876
+ kind: 'workspace',
1877
+ id: input.workspaceId,
1878
+ workspaceId: input.workspaceId,
1879
+ });
1880
+ let filtered = rows;
1881
+ if (input.officeId) {
1882
+ const here = await tx
1883
+ .select({ personId: officeAssignments.personId })
1884
+ .from(officeAssignments)
1885
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
1886
+ const ids = new Set(here.map((h) => h.personId));
1887
+ filtered = filtered.filter((r) => ids.has(r.personId));
1888
+ }
1889
+ if (input.orgUnitId) {
1890
+ const ids = new Set(await unitMemberIds(tx, input.workspaceId, input.orgUnitId, true));
1891
+ filtered = filtered.filter((r) => ids.has(r.personId));
1892
+ }
1893
+ return filtered.map((r) => {
1894
+ const type = typeById.get(r.leaveTypeId);
1895
+ return {
1896
+ personId: r.personId,
1897
+ displayName: nameById.get(r.personId) ?? 'Unknown',
1898
+ requestId: r.id,
1899
+ startsOn: r.startsOn,
1900
+ endsOn: r.endsOn,
1901
+ status: r.status,
1902
+ leaveTypeName: maySeeType ? (type?.name ?? null) : null,
1903
+ color: type?.color ?? null,
1904
+ };
1905
+ });
1906
+ })),
1907
+ },
1908
+ },
1909
+ // ================================================================= approvals
1910
+ approvals: {
1911
+ /**
1912
+ * Everything waiting on the caller. No permission: an inbox of what *you* must decide is
1913
+ * yours by definition, and the engine only lists steps you are named on.
1914
+ */
1915
+ inbox: scoped.approvals.inbox.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
1916
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1917
+ if (!me)
1918
+ return { items: [], nextCursor: null };
1919
+ const rows = await approvals.inboxFor(tx, input.workspaceId, me.id, input.includeDecided, input.limit);
1920
+ const items = [];
1921
+ for (const r of rows)
1922
+ items.push(await hydrateApproval(tx, r));
1923
+ return { items, nextCursor: null };
1924
+ })),
1925
+ get: scoped.approvals.get.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1926
+ const [row] = await tx
1927
+ .select()
1928
+ .from(approvalRequests)
1929
+ .where(and(eq(approvalRequests.workspaceId, input.workspaceId), eq(approvalRequests.id, input.requestId)))
1930
+ .limit(1);
1931
+ if (!row)
1932
+ throw KernError.notFound('Approval request');
1933
+ return hydrateApproval(tx, row);
1934
+ })),
1935
+ decide: scoped.approvals.decide.handler(async ({ input, context }) => {
1936
+ const outcome = await db.withWorkspace(input.workspaceId, async (tx) => {
1937
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
1938
+ if (!me)
1939
+ throw KernError.forbidden('You have no employee record in this workspace');
1940
+ const result = await approvals.decide(tx, input.workspaceId, input.requestId, me.id, input.decision, input.comment ?? null, input.onBehalfOfId ?? null);
1941
+ // The approval engine knows nothing about leave. Applying the decision to the subject is
1942
+ // the caller's job, which is what keeps the engine reusable for regularization and
1943
+ // overtime later.
1944
+ const request = result.request;
1945
+ if (request.subjectType === 'leave') {
1946
+ if (result.status === 'approved')
1947
+ await applyApproval(tx, input.workspaceId, request.subjectId, context.principal.userId ?? null);
1948
+ else if (result.status === 'rejected')
1949
+ await tx
1950
+ .update(leaveRequests)
1951
+ .set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
1952
+ .where(eq(leaveRequests.id, request.subjectId));
1953
+ }
1954
+ else if (request.subjectType === 'regularization') {
1955
+ if (result.status === 'approved')
1956
+ await applyRegularization(tx, input.workspaceId, request.subjectId);
1957
+ else if (result.status === 'rejected')
1958
+ await tx
1959
+ .update(regularizations)
1960
+ .set({ status: 'rejected' })
1961
+ .where(eq(regularizations.id, request.subjectId));
1962
+ }
1963
+ const [fresh] = await tx
1964
+ .select()
1965
+ .from(approvalRequests)
1966
+ .where(eq(approvalRequests.id, input.requestId));
1967
+ return { hydrated: await hydrateApproval(tx, fresh), request: fresh };
1968
+ });
1969
+ await kernel.emit(hrEvents.approvalDecided, {
1970
+ requestId: outcome.request.id,
1971
+ workspaceId: input.workspaceId,
1972
+ subjectType: outcome.request.subjectType,
1973
+ subjectId: outcome.request.subjectId,
1974
+ status: outcome.request.status,
1975
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
1976
+ await changed(input.workspaceId, 'approval', outcome.request.id, 'updated');
1977
+ return outcome.hydrated;
1978
+ }),
1979
+ chains: {
1980
+ list: scoped.approvals.chains.list
1981
+ .use(cap('approvals'))
1982
+ .use(requires('hr.approval.manage'))
1983
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
1984
+ const where = [
1985
+ eq(approvalChains.workspaceId, input.workspaceId),
1986
+ isNull(approvalChains.archivedAt),
1987
+ ];
1988
+ if (input.subjectType)
1989
+ where.push(eq(approvalChains.subjectType, input.subjectType));
1990
+ const rows = await tx
1991
+ .select()
1992
+ .from(approvalChains)
1993
+ .where(and(...where))
1994
+ .orderBy(asc(approvalChains.name));
1995
+ return rows.map(toChain);
1996
+ })),
1997
+ create: scoped.approvals.chains.create
1998
+ .use(cap('approvals'))
1999
+ .use(requires('hr.approval.manage'))
2000
+ .handler(async ({ input }) => {
2001
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
2002
+ if (input.isDefault)
2003
+ await clearDefaultChain(tx, input.workspaceId, input.subjectType);
2004
+ const [created] = await tx
2005
+ .insert(approvalChains)
2006
+ .values({
2007
+ id: uuidv7(),
2008
+ workspaceId: input.workspaceId,
2009
+ name: input.name,
2010
+ subjectType: input.subjectType,
2011
+ spec: input.spec,
2012
+ isDefault: input.isDefault,
2013
+ })
2014
+ .returning();
2015
+ return created;
2016
+ });
2017
+ await changed(input.workspaceId, 'approval_chain', row.id, 'created');
2018
+ return toChain(row);
2019
+ }),
2020
+ update: scoped.approvals.chains.update
2021
+ .use(cap('approvals'))
2022
+ .use(requires('hr.approval.manage'))
2023
+ .handler(async ({ input }) => {
2024
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
2025
+ const [existing] = await tx
2026
+ .select()
2027
+ .from(approvalChains)
2028
+ .where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId)))
2029
+ .limit(1);
2030
+ if (!existing)
2031
+ throw KernError.notFound('Approval chain');
2032
+ if (input.isDefault)
2033
+ await clearDefaultChain(tx, input.workspaceId, existing.subjectType);
2034
+ const set = { updatedAt: new Date() };
2035
+ if (input.name !== undefined)
2036
+ set.name = input.name;
2037
+ if (input.spec !== undefined)
2038
+ set.spec = input.spec;
2039
+ if (input.isDefault !== undefined)
2040
+ set.isDefault = input.isDefault;
2041
+ const [updated] = await tx
2042
+ .update(approvalChains)
2043
+ .set(set)
2044
+ .where(eq(approvalChains.id, input.chainId))
2045
+ .returning();
2046
+ return updated;
2047
+ });
2048
+ await changed(input.workspaceId, 'approval_chain', row.id, 'updated');
2049
+ return toChain(row);
2050
+ }),
2051
+ archive: scoped.approvals.chains.archive
2052
+ .use(cap('approvals'))
2053
+ .use(requires('hr.approval.manage'))
2054
+ .handler(async ({ input }) => {
2055
+ // In-flight requests carry their own snapshot of the chain, so archiving one cannot
2056
+ // strand an approval half-signed.
2057
+ await db.withWorkspace(input.workspaceId, (tx) => tx
2058
+ .update(approvalChains)
2059
+ .set({ archivedAt: new Date(), isDefault: false })
2060
+ .where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId))));
2061
+ await changed(input.workspaceId, 'approval_chain', input.chainId, 'deleted');
2062
+ return { ok: true };
2063
+ }),
2064
+ },
2065
+ delegations: scoped.approvals.delegations
2066
+ .use(cap('approvals'))
2067
+ .use(requires('hr.approval.delegate'))
2068
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
2069
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
2070
+ const rows = await tx
2071
+ .select()
2072
+ .from(delegations)
2073
+ .where(and(eq(delegations.workspaceId, input.workspaceId), or(eq(delegations.fromPersonId, personId), eq(delegations.toPersonId, personId))))
2074
+ .orderBy(desc(delegations.startsOn));
2075
+ return rows.map(toDelegation);
2076
+ })),
2077
+ delegate: scoped.approvals.delegate
2078
+ .use(cap('approvals'))
2079
+ .use(requires('hr.approval.delegate'))
2080
+ .handler(async ({ input, context }) => {
2081
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
2082
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
2083
+ if (!me)
2084
+ throw KernError.forbidden('You have no employee record in this workspace');
2085
+ if (me.id === input.toPersonId)
2086
+ throw KernError.badRequest('You cannot delegate your approvals to yourself.');
2087
+ if (input.endsOn < input.startsOn)
2088
+ throw KernError.badRequest('A delegation cannot end before it starts.');
2089
+ const [created] = await tx
2090
+ .insert(delegations)
2091
+ .values({
2092
+ id: uuidv7(),
2093
+ workspaceId: input.workspaceId,
2094
+ fromPersonId: me.id,
2095
+ toPersonId: input.toPersonId,
2096
+ subjectType: input.subjectType ?? null,
2097
+ startsOn: input.startsOn,
2098
+ endsOn: input.endsOn,
2099
+ reason: input.reason ?? null,
2100
+ })
2101
+ .returning();
2102
+ return created;
2103
+ });
2104
+ await changed(input.workspaceId, 'delegation', row.id, 'created');
2105
+ return toDelegation(row);
2106
+ }),
2107
+ revokeDelegation: scoped.approvals.revokeDelegation
2108
+ .use(cap('approvals'))
2109
+ .use(requires('hr.approval.delegate'))
2110
+ .handler(async ({ input, context }) => {
2111
+ await db.withWorkspace(input.workspaceId, async (tx) => {
2112
+ const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
2113
+ const [row] = await tx
2114
+ .select()
2115
+ .from(delegations)
2116
+ .where(and(eq(delegations.workspaceId, input.workspaceId), eq(delegations.id, input.delegationId)))
2117
+ .limit(1);
2118
+ if (!row)
2119
+ throw KernError.notFound('Delegation');
2120
+ // Only the person who gave it away may take it back — otherwise a delegate could quietly
2121
+ // extend their own authority by revoking the competition.
2122
+ if (row.fromPersonId !== me?.id)
2123
+ throw KernError.forbidden('Only the person who delegated may revoke it');
2124
+ await tx.delete(delegations).where(eq(delegations.id, row.id));
2125
+ });
2126
+ await changed(input.workspaceId, 'delegation', input.delegationId, 'deleted');
2127
+ return { ok: true };
2128
+ }),
2129
+ },
1223
2130
  // ================================================================= custom fields
1224
2131
  fields: {
1225
2132
  list: scoped.fields.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
@@ -1495,6 +2402,334 @@ export function implement_(kernel) {
1495
2402
  const parent = await parentPath(tx, workspaceId, parentId);
1496
2403
  return parent ? `${parent}.${label}` : label;
1497
2404
  }
2405
+ /**
2406
+ * The person a call is about: the one named, or the caller.
2407
+ *
2408
+ * Reading somebody else's balance needs `hr.leave.view_team`; reading your own needs nothing
2409
+ * beyond being an employee. Collapsing those into one permission would either hide your own
2410
+ * balance from you or show you everybody's.
2411
+ */
2412
+ async function personFor(tx, workspaceId, context, personId) {
2413
+ const me = await svc.byUserId(tx, workspaceId, context.principal.userId ?? '');
2414
+ if (!personId) {
2415
+ if (!me)
2416
+ throw KernError.notFound('Your employee record');
2417
+ return me.id;
2418
+ }
2419
+ if (me && me.id === personId)
2420
+ return personId;
2421
+ await kernel.authz.require(context.principal, 'hr.leave.view_team', {
2422
+ kind: 'workspace',
2423
+ id: workspaceId,
2424
+ workspaceId,
2425
+ });
2426
+ return personId;
2427
+ }
2428
+ async function loadRequest(tx, workspaceId, requestId) {
2429
+ const [row] = await tx
2430
+ .select()
2431
+ .from(leaveRequests)
2432
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
2433
+ .limit(1);
2434
+ if (!row)
2435
+ throw KernError.notFound('Leave request');
2436
+ return row;
2437
+ }
2438
+ /**
2439
+ * What a request would cost, and every reason it would be refused.
2440
+ *
2441
+ * Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
2442
+ * submission is a preview that eventually lies. The blockers are returned rather than thrown here
2443
+ * so the screen can show all of them at once instead of one per round trip.
2444
+ */
2445
+ async function simulate(tx, workspaceId, personId, input) {
2446
+ const blockers = [];
2447
+ if (input.endsOn < input.startsOn)
2448
+ blockers.push({ code: 'range', message: 'The end date is before the start date.' });
2449
+ const [type] = await tx
2450
+ .select()
2451
+ .from(leaveTypes)
2452
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
2453
+ .limit(1);
2454
+ if (!type)
2455
+ throw KernError.notFound('Leave type');
2456
+ if (type.archivedAt)
2457
+ blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
2458
+ const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
2459
+ const calendarDaysInRange = resolution.calendarId
2460
+ ? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
2461
+ : [];
2462
+ const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
2463
+ ? calendarDaysInRange.map((d) => ({
2464
+ date: d.date,
2465
+ name: d.name,
2466
+ workingFraction: d.workingFraction,
2467
+ }))
2468
+ : []);
2469
+ // Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
2470
+ // holiday still costs nothing rather than costing half of nothing.
2471
+ const days = results.map((r) => {
2472
+ let fraction = r.fraction;
2473
+ if (r.date === input.startsOn && input.startPart === 'afternoon')
2474
+ fraction = Math.min(fraction, 0.5);
2475
+ if (r.date === input.endsOn && input.endPart === 'morning')
2476
+ fraction = Math.min(fraction, 0.5);
2477
+ return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
2478
+ });
2479
+ const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
2480
+ const minutes = type.unit === 'hour' && input.hours
2481
+ ? Math.round(input.hours * 60)
2482
+ : Math.round(workingDaysTotal * MINUTES_PER_DAY);
2483
+ if (minutes <= 0)
2484
+ blockers.push({
2485
+ code: 'empty',
2486
+ message: 'That range contains no working days.',
2487
+ });
2488
+ const year = yearOf(input.startsOn);
2489
+ const balances = await ledger.balances(tx, workspaceId, personId, year);
2490
+ const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
2491
+ const before = balance?.availableMinutes ?? 0;
2492
+ const after = before - minutes;
2493
+ if (after < 0 && !type.allowNegative)
2494
+ blockers.push({
2495
+ code: 'insufficient',
2496
+ message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
2497
+ });
2498
+ if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
2499
+ blockers.push({
2500
+ code: 'below_floor',
2501
+ message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
2502
+ });
2503
+ // Overlap is refused by a unique index as well; checking here turns a constraint violation into
2504
+ // a sentence naming the dates.
2505
+ const counted = days.filter((d) => d.counted).map((d) => d.date);
2506
+ if (counted.length) {
2507
+ const clash = await tx
2508
+ .select({ date: leaveRequestDays.date })
2509
+ .from(leaveRequestDays)
2510
+ .where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
2511
+ .limit(3);
2512
+ if (clash.length)
2513
+ blockers.push({
2514
+ code: 'overlap',
2515
+ message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
2516
+ });
2517
+ }
2518
+ if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
2519
+ blockers.push({
2520
+ code: 'document_required',
2521
+ message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
2522
+ });
2523
+ return {
2524
+ workingDays: workingDaysTotal,
2525
+ minutes,
2526
+ days,
2527
+ balanceBeforeMinutes: before,
2528
+ balanceAfterMinutes: after,
2529
+ blockers,
2530
+ };
2531
+ }
2532
+ /**
2533
+ * Turn an approved request into a ledger consumption.
2534
+ *
2535
+ * The working days are **recomputed here** rather than trusted from submission time: a holiday
2536
+ * can be added to the calendar between asking and approving, and the number that costs somebody
2537
+ * balance should be the one that was true when it was granted.
2538
+ */
2539
+ async function applyApproval(tx, workspaceId, leaveRequestId, actorId) {
2540
+ const request = await loadRequest(tx, workspaceId, leaveRequestId);
2541
+ if (request.status === 'approved')
2542
+ return;
2543
+ const sim = await simulate(tx, workspaceId, request.personId, {
2544
+ leaveTypeId: request.leaveTypeId,
2545
+ startsOn: request.startsOn,
2546
+ endsOn: request.endsOn,
2547
+ startPart: request.startPart,
2548
+ endPart: request.endPart,
2549
+ hours: request.hours === null ? null : Number.parseFloat(request.hours),
2550
+ });
2551
+ await ledger.append(tx, workspaceId, {
2552
+ personId: request.personId,
2553
+ leaveTypeId: request.leaveTypeId,
2554
+ kind: 'consumption',
2555
+ amountMinutes: -sim.minutes,
2556
+ effectiveOn: request.startsOn,
2557
+ periodYear: yearOf(request.startsOn),
2558
+ requestId: request.id,
2559
+ reason: null,
2560
+ createdBy: actorId,
2561
+ });
2562
+ await tx
2563
+ .update(leaveRequestDays)
2564
+ .set({ status: 'approved' })
2565
+ .where(eq(leaveRequestDays.requestId, request.id));
2566
+ await tx
2567
+ .update(leaveRequests)
2568
+ .set({
2569
+ status: 'approved',
2570
+ minutes: sim.minutes,
2571
+ workingDays: String(sim.workingDays),
2572
+ decidedAt: new Date(),
2573
+ updatedAt: new Date(),
2574
+ })
2575
+ .where(eq(leaveRequests.id, request.id));
2576
+ }
2577
+ async function clearDefaultChain(tx, workspaceId, subjectType) {
2578
+ await tx
2579
+ .update(approvalChains)
2580
+ .set({ isDefault: false })
2581
+ .where(and(eq(approvalChains.workspaceId, workspaceId), eq(approvalChains.subjectType, subjectType), eq(approvalChains.isDefault, true)));
2582
+ }
2583
+ /** An approval request with its steps and decisions, which is the only useful shape. */
2584
+ async function hydrateApproval(tx, row) {
2585
+ const steps = await tx
2586
+ .select()
2587
+ .from(approvalSteps)
2588
+ .where(eq(approvalSteps.requestId, row.id))
2589
+ .orderBy(asc(approvalSteps.stepIndex));
2590
+ const stepIds = steps.map((s) => s.id);
2591
+ const decisions = stepIds.length
2592
+ ? await tx.select().from(approvalDecisions).where(inArray(approvalDecisions.stepId, stepIds))
2593
+ : [];
2594
+ return {
2595
+ ...row,
2596
+ subjectType: row.subjectType,
2597
+ status: row.status,
2598
+ requestedAt: row.requestedAt.toISOString(),
2599
+ decidedAt: row.decidedAt?.toISOString() ?? null,
2600
+ steps: steps.map((s) => ({
2601
+ ...s,
2602
+ mode: s.mode,
2603
+ status: s.status,
2604
+ dueAt: s.dueAt?.toISOString() ?? null,
2605
+ escalatedAt: s.escalatedAt?.toISOString() ?? null,
2606
+ decisions: decisions
2607
+ .filter((d) => d.stepId === s.id)
2608
+ .map((d) => ({
2609
+ ...d,
2610
+ decision: d.decision,
2611
+ at: d.at.toISOString(),
2612
+ })),
2613
+ })),
2614
+ };
2615
+ }
2616
+ /**
2617
+ * Everything a punch needs: the person's zone, today's business date, and their schedule.
2618
+ *
2619
+ * The zone comes from the resolution ladder — their primary office unless they have an override —
2620
+ * so a punch made on a business trip still counts towards the month they are employed in.
2621
+ */
2622
+ async function clockContext(tx, workspaceId, personId) {
2623
+ const today = todayIso();
2624
+ const resolution = await resolve.forPerson(tx, workspaceId, personId, today);
2625
+ const schedule = await attendance.scheduleFor(tx, workspaceId, personId, today);
2626
+ const businessDate = businessDateFor(Date.now(), resolution.timezone, schedule.shiftFor(todayIn(resolution.timezone)));
2627
+ return { timezone: resolution.timezone, businessDate, schedule, resolution };
2628
+ }
2629
+ /**
2630
+ * One punch, and the day rebuilt straight afterwards.
2631
+ *
2632
+ * Recomputing inline rather than in a background job: the clock widget shows a total, and a person
2633
+ * who clocks out wants to see it immediately. The day sheet is a projection, so doing it twice
2634
+ * costs nothing.
2635
+ */
2636
+ async function punch(input, context, direction) {
2637
+ const row = await db.withWorkspace(input.workspaceId, async (tx) => {
2638
+ const personId = await personFor(tx, input.workspaceId, context, input.personId);
2639
+ const { timezone, businessDate, schedule, resolution } = await clockContext(tx, input.workspaceId, personId);
2640
+ // Refuse the transitions that make no sense, with a sentence rather than a constraint error:
2641
+ // clocking in twice, or out when never in, is somebody double-tapping a button.
2642
+ const existing = await attendance.punchesOn(tx, input.workspaceId, personId, businessDate);
2643
+ const open = openState(existing);
2644
+ if (direction === 'in' && open.clockedIn)
2645
+ throw KernError.conflict('You are already clocked in.');
2646
+ if (direction === 'out' && !open.clockedIn)
2647
+ throw KernError.conflict('You are not clocked in.');
2648
+ if (direction === 'break_start' && !open.clockedIn)
2649
+ throw KernError.conflict('Clock in before starting a break.');
2650
+ if (direction === 'break_start' && open.onBreak)
2651
+ throw KernError.conflict('You are already on a break.');
2652
+ if (direction === 'break_end' && !open.onBreak)
2653
+ throw KernError.conflict('You are not on a break.');
2654
+ const punchRow = await attendance.record(tx, input.workspaceId, {
2655
+ personId,
2656
+ direction,
2657
+ timezone,
2658
+ method: input.method ?? 'web',
2659
+ clientReportedAt: input.clientReportedAt ?? null,
2660
+ officeId: resolution.primaryOfficeId,
2661
+ geo: input.geo ?? null,
2662
+ note: input.note ?? null,
2663
+ idempotencyKey: input.idempotencyKey ?? null,
2664
+ }, schedule);
2665
+ await attendance.recomputeDay(tx, input.workspaceId, personId, punchRow.businessDate, timezone, schedule);
2666
+ return punchRow;
2667
+ });
2668
+ await kernel.emit(hrEvents.punchRecorded, {
2669
+ punchId: row.id,
2670
+ workspaceId: input.workspaceId,
2671
+ personId: row.personId,
2672
+ direction: row.direction,
2673
+ businessDate: row.businessDate,
2674
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
2675
+ await changed(input.workspaceId, 'attendance_day', row.personId, 'updated');
2676
+ return toPunch(row);
2677
+ }
2678
+ /** Whether a person is currently clocked in or on a break, from their punches so far. */
2679
+ function openState(rows) {
2680
+ let clockedIn = false;
2681
+ let onBreak = false;
2682
+ for (const r of rows) {
2683
+ if (r.direction === 'in')
2684
+ clockedIn = true;
2685
+ else if (r.direction === 'out') {
2686
+ clockedIn = false;
2687
+ onBreak = false;
2688
+ }
2689
+ else if (r.direction === 'break_start')
2690
+ onBreak = true;
2691
+ else if (r.direction === 'break_end')
2692
+ onBreak = false;
2693
+ }
2694
+ return { clockedIn, onBreak };
2695
+ }
2696
+ /**
2697
+ * Apply an approved correction: write the proposed punches, void what they replace, rebuild.
2698
+ *
2699
+ * Nothing is edited. The original punch keeps its row and gains a pointer to what superseded it,
2700
+ * so a corrected timesheet and an edited one stay distinguishable — which is the entire reason
2701
+ * regularization exists rather than an update statement.
2702
+ */
2703
+ async function applyRegularization(tx, workspaceId, regularizationId) {
2704
+ const [row] = await tx
2705
+ .select()
2706
+ .from(regularizations)
2707
+ .where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.id, regularizationId)))
2708
+ .limit(1);
2709
+ if (!row || row.status === 'approved')
2710
+ return;
2711
+ if (row.punchId)
2712
+ await attendance.voidPunch(tx, workspaceId, row.punchId, 'Regularized', null);
2713
+ const { timezone, schedule } = await clockContext(tx, workspaceId, row.personId);
2714
+ for (const proposal of row.proposed)
2715
+ await tx.insert(punches).values({
2716
+ id: uuidv7(),
2717
+ workspaceId,
2718
+ personId: row.personId,
2719
+ direction: proposal.direction,
2720
+ at: new Date(proposal.at),
2721
+ businessDate: row.businessDate,
2722
+ timezone,
2723
+ method: 'manual',
2724
+ trust: 'trusted',
2725
+ note: `Regularization ${row.id}`,
2726
+ });
2727
+ await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
2728
+ await tx
2729
+ .update(regularizations)
2730
+ .set({ status: 'approved', appliedAt: new Date() })
2731
+ .where(eq(regularizations.id, row.id));
2732
+ }
1498
2733
  async function emitCalendarChanged(workspaceId, calendarId, from, to, actorId) {
1499
2734
  await kernel.emit(hrEvents.calendarChanged, { calendarId, workspaceId, from, to }, { workspaceId, actorId });
1500
2735
  await changed(workspaceId, 'calendar', calendarId, 'updated');
@@ -1534,4 +2769,72 @@ const toResolvedDay = (r, fromCalendarId, fromCalendarName, overrides) => ({
1534
2769
  fromCalendarName,
1535
2770
  overrides,
1536
2771
  });
2772
+ const toLeaveType = (r) => ({
2773
+ ...r,
2774
+ unit: r.unit,
2775
+ archivedAt: r.archivedAt?.toISOString() ?? null,
2776
+ });
2777
+ const toLedgerEntry = (r) => ({
2778
+ ...r,
2779
+ kind: r.kind,
2780
+ createdAt: r.createdAt.toISOString(),
2781
+ });
2782
+ const toLeaveRequest = (r) => ({
2783
+ ...r,
2784
+ startPart: r.startPart,
2785
+ endPart: r.endPart,
2786
+ status: r.status,
2787
+ hours: r.hours === null ? null : Number.parseFloat(r.hours),
2788
+ workingDays: Number.parseFloat(r.workingDays),
2789
+ decidedAt: r.decidedAt?.toISOString() ?? null,
2790
+ createdAt: r.createdAt.toISOString(),
2791
+ updatedAt: r.updatedAt.toISOString(),
2792
+ });
2793
+ const toChain = (r) => ({
2794
+ ...r,
2795
+ subjectType: r.subjectType,
2796
+ spec: r.spec,
2797
+ archivedAt: r.archivedAt?.toISOString() ?? null,
2798
+ });
2799
+ const toDelegation = (r) => ({
2800
+ ...r,
2801
+ subjectType: (r.subjectType ?? null),
2802
+ createdAt: r.createdAt.toISOString(),
2803
+ });
2804
+ const toPunch = (r) => ({
2805
+ ...r,
2806
+ direction: r.direction,
2807
+ method: r.method,
2808
+ trust: r.trust,
2809
+ geo: r.geo ?? null,
2810
+ at: r.at.toISOString(),
2811
+ clientReportedAt: r.clientReportedAt?.toISOString() ?? null,
2812
+ createdAt: r.createdAt.toISOString(),
2813
+ });
2814
+ const toAttendanceDay = (r) => ({
2815
+ ...r,
2816
+ status: r.status,
2817
+ firstIn: r.firstIn?.toISOString() ?? null,
2818
+ lastOut: r.lastOut?.toISOString() ?? null,
2819
+ computedAt: r.computedAt.toISOString(),
2820
+ });
2821
+ const toSchedule = (r) => ({
2822
+ ...r,
2823
+ kind: r.kind,
2824
+ week: r.week,
2825
+ tzMode: r.tzMode,
2826
+ roundingDirection: r.roundingDirection,
2827
+ archivedAt: r.archivedAt?.toISOString() ?? null,
2828
+ });
2829
+ const toScheduleAssignment = (r) => ({
2830
+ ...r,
2831
+ createdAt: r.createdAt.toISOString(),
2832
+ });
2833
+ const toRegularization = (r) => ({
2834
+ ...r,
2835
+ status: r.status,
2836
+ proposed: r.proposed,
2837
+ appliedAt: r.appliedAt?.toISOString() ?? null,
2838
+ createdAt: r.createdAt.toISOString(),
2839
+ });
1537
2840
  //# sourceMappingURL=router.js.map