@kernhq/module-hr 0.14.1 → 0.16.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 (35) hide show
  1. package/dist/contract/router.d.ts +14 -2
  2. package/dist/contract/router.d.ts.map +1 -1
  3. package/dist/contract/router.js +11 -2
  4. package/dist/contract/router.js.map +1 -1
  5. package/dist/server/index.d.ts.map +1 -1
  6. package/dist/server/index.js +51 -0
  7. package/dist/server/index.js.map +1 -1
  8. package/dist/server/jobs.d.ts +5 -2
  9. package/dist/server/jobs.d.ts.map +1 -1
  10. package/dist/server/jobs.js +109 -2
  11. package/dist/server/jobs.js.map +1 -1
  12. package/dist/server/router.d.ts +122 -2
  13. package/dist/server/router.d.ts.map +1 -1
  14. package/dist/server/router.js +448 -274
  15. package/dist/server/router.js.map +1 -1
  16. package/dist/server/schema.d.ts +85 -0
  17. package/dist/server/schema.d.ts.map +1 -1
  18. package/dist/server/schema.js +32 -0
  19. package/dist/server/schema.js.map +1 -1
  20. package/dist/server/services/approvals.d.ts +150 -6
  21. package/dist/server/services/approvals.d.ts.map +1 -1
  22. package/dist/server/services/approvals.js +428 -26
  23. package/dist/server/services/approvals.js.map +1 -1
  24. package/migrations/0010_approval_timeouts.sql +28 -0
  25. package/migrations/meta/0010_snapshot.json +4263 -0
  26. package/migrations/meta/_journal.json +8 -1
  27. package/package.json +1 -1
  28. package/src/client/components/DecisionDialog.svelte +99 -7
  29. package/src/client/messages.ts +85 -0
  30. package/src/client/mock.ts +256 -15
  31. package/src/client/pages/ApprovalsPage.svelte +256 -27
  32. package/src/client/pages/DirectoryPage.svelte +1 -1
  33. package/src/client/query.ts +2 -2
  34. package/src/client/widgets/ApprovalsWidget.svelte +120 -9
  35. package/src/contract/router.ts +11 -2
@@ -45,7 +45,11 @@ import type { Period, Policy, PolicyAssignment, PolicySubjectKind } from '../con
45
45
  * Every sentence below is copied from `src/server/router.ts`, because the widget renders the
46
46
  * server's own words rather than a translated string.
47
47
  */
48
- function refuse(code: 'CONFLICT' | 'NOT_FOUND' | 'BAD_REQUEST', message: string, reason?: string): never {
48
+ function refuse(
49
+ code: 'CONFLICT' | 'NOT_FOUND' | 'BAD_REQUEST' | 'FORBIDDEN',
50
+ message: string,
51
+ reason?: string,
52
+ ): never {
49
53
  // A declaration, not a `const` arrow: TypeScript only narrows on a `never` return for one of
50
54
  // those, so an arrow would leave every caller believing the row after the guard is still optional.
51
55
  //
@@ -1495,7 +1499,87 @@ export function createMockHrApi() {
1495
1499
  }
1496
1500
  }
1497
1501
 
1498
- const delegations: Array<Record<string, unknown>> = []
1502
+ /**
1503
+ * One live delegation, so a delegate deciding in somebody's place is visible at all.
1504
+ *
1505
+ * Sanne has handed her approvals to Ayşe — who is `people.me` — for a fortnight around today. The
1506
+ * pending leave request below names Sanne and *not* Ayşe on its first step, so the only way that
1507
+ * row can be decided is through this delegation. Without both halves the feature degrades to
1508
+ * "decide as yourself", which is indistinguishable from the feature not existing.
1509
+ */
1510
+ const delegations: Array<Record<string, unknown>> = [
1511
+ {
1512
+ id: id('de01'),
1513
+ fromPersonId: people[1]!.id,
1514
+ toPersonId: people[0]!.id,
1515
+ subjectType: null,
1516
+ startsOn: day(-4),
1517
+ endsOn: day(10),
1518
+ reason: 'Parental leave',
1519
+ createdAt: iso(5 * 86_400_000),
1520
+ },
1521
+ // The narrow one, and the reason both are here. `subjectType: null` above delegates everything;
1522
+ // this delegates *time off only*, so the same deputy can decide Mehmet's leave and must not
1523
+ // touch his attendance corrections. With only a wildcard seeded, the scope check has nothing to
1524
+ // be wrong about and the fix that added it would look like it did nothing.
1525
+ {
1526
+ id: id('de02'),
1527
+ fromPersonId: people[2]!.id,
1528
+ toPersonId: people[0]!.id,
1529
+ subjectType: 'leave',
1530
+ startsOn: day(-2),
1531
+ endsOn: day(14),
1532
+ reason: 'Covering time off only',
1533
+ createdAt: iso(3 * 86_400_000),
1534
+ },
1535
+ ]
1536
+
1537
+ /**
1538
+ * Whether the reader may act on a step, given what the request is about.
1539
+ *
1540
+ * A set per delegator, not a single value: somebody may hold two delegations from the same person
1541
+ * with different scopes, and taking the last row would silently drop the other grant.
1542
+ */
1543
+ const mayDecide = (approverIds: string[], subjectType: string) => {
1544
+ const me = people[0]!.id
1545
+ if (approverIds.includes(me)) return true
1546
+ return delegations.some(
1547
+ (d) =>
1548
+ d.toPersonId === me &&
1549
+ approverIds.includes(d.fromPersonId as string) &&
1550
+ (d.subjectType === null || d.subjectType === subjectType) &&
1551
+ String(d.startsOn) <= day(0) &&
1552
+ String(d.endsOn) >= day(0),
1553
+ )
1554
+ }
1555
+
1556
+ let stepCounter = 0
1557
+ const step = (
1558
+ requestId: string,
1559
+ stepIndex: number,
1560
+ name: string,
1561
+ approverIds: string[],
1562
+ over: Record<string, unknown> = {},
1563
+ ) => {
1564
+ const row = {
1565
+ id: id(`5e${(++stepCounter).toString(16).padStart(4, '0')}`),
1566
+ requestId,
1567
+ stepIndex,
1568
+ name,
1569
+ mode: 'any' as const,
1570
+ minApprovals: 1,
1571
+ approverIds,
1572
+ status: 'pending' as string,
1573
+ dueAt: null as string | null,
1574
+ escalatedAt: null,
1575
+ decisions: [] as Array<Record<string, unknown>>,
1576
+ ...over,
1577
+ }
1578
+ // The factory owns the id, so it owns the link back to it. Writing `stepId` in the seed instead
1579
+ // would make every decision depend on the order these are constructed in.
1580
+ for (const decision of row.decisions) decision.stepId = row.id
1581
+ return row
1582
+ }
1499
1583
 
1500
1584
  const approvalRequests = [
1501
1585
  {
@@ -1512,7 +1596,8 @@ export function createMockHrApi() {
1512
1596
  requesterName: people[1]!.displayName,
1513
1597
  requestedAt: iso(3600_000),
1514
1598
  decidedAt: null as string | null,
1515
- steps: [] as Array<Record<string, unknown>>,
1599
+ // Named on this step: Sanne's manager, who is Ayşe — reachable directly.
1600
+ steps: [step(id('f001'), 0, 'Manager', [people[0]!.id])] as Array<Record<string, unknown>>,
1516
1601
  },
1517
1602
  {
1518
1603
  id: id('f002'),
@@ -1522,13 +1607,39 @@ export function createMockHrApi() {
1522
1607
  summary: `Correction for ${WD[1]}`,
1523
1608
  summaryParams: { date: WD[1]! } as Record<string, string | number> | null,
1524
1609
  status: 'pending' as string,
1525
- currentStep: 0,
1610
+ // On the *second* step, because the first is already decided below. A request parked on a
1611
+ // step it has finished would offer the caller a decision they have already made.
1612
+ currentStep: 1,
1526
1613
  requestedBy: null,
1527
1614
  requesterPersonId: people[2]!.id,
1528
1615
  requesterName: people[2]!.displayName,
1529
1616
  requestedAt: iso(7200_000),
1530
1617
  decidedAt: null as string | null,
1531
- steps: [{ stepIndex: 0 }, { stepIndex: 1 }] as Array<Record<string, unknown>>,
1618
+ /**
1619
+ * Two steps, and the reason this request is the interesting one.
1620
+ *
1621
+ * Step 0 is decided, so the step counter has something to count. Step 1 names **Sanne** and
1622
+ * not the caller, so the only route to a decision on it is the delegation she left — which is
1623
+ * what makes the row read "on behalf of Sanne de Vries" rather than offering the caller's own
1624
+ * name and quietly proving nothing.
1625
+ */
1626
+ steps: [
1627
+ step(id('f002'), 0, 'Manager', [people[0]!.id], {
1628
+ status: 'approved',
1629
+ decisions: [
1630
+ {
1631
+ id: id('dec01'),
1632
+ stepId: '',
1633
+ approverId: people[0]!.id,
1634
+ onBehalfOfId: null,
1635
+ decision: 'approve',
1636
+ comment: null,
1637
+ at: iso(5400_000),
1638
+ },
1639
+ ],
1640
+ }),
1641
+ step(id('f002'), 1, 'HR', [people[1]!.id]),
1642
+ ] as Array<Record<string, unknown>>,
1532
1643
  },
1533
1644
  {
1534
1645
  id: id('f003'),
@@ -1544,10 +1655,67 @@ export function createMockHrApi() {
1544
1655
  requesterName: people[0]!.displayName,
1545
1656
  requestedAt: iso(20 * 86_400_000),
1546
1657
  decidedAt: iso(19 * 86_400_000) as string | null,
1547
- steps: [] as Array<Record<string, unknown>>,
1658
+ steps: [
1659
+ step(id('f003'), 0, 'Manager', [people[1]!.id], {
1660
+ status: 'approved',
1661
+ decisions: [
1662
+ {
1663
+ id: id('dec02'),
1664
+ stepId: '',
1665
+ approverId: people[1]!.id,
1666
+ onBehalfOfId: null,
1667
+ decision: 'approve',
1668
+ comment: null,
1669
+ at: iso(19 * 86_400_000),
1670
+ },
1671
+ ],
1672
+ }),
1673
+ ] as Array<Record<string, unknown>>,
1548
1674
  },
1549
1675
  ]
1550
1676
 
1677
+ /**
1678
+ * The pair that makes the narrow delegation observable.
1679
+ *
1680
+ * Both name Mehmet, who has delegated **time off only**. So his deputy sees the leave request and
1681
+ * does not see the correction — and the difference between the two rows is the only thing on
1682
+ * screen that shows a scoped delegation is scoped.
1683
+ */
1684
+ approvalRequests.push(
1685
+ {
1686
+ id: id('f004'),
1687
+ workspaceId: '',
1688
+ subjectType: 'leave' as const,
1689
+ subjectId: id('c005'),
1690
+ summary: `2 day(s) from ${day(21)}`,
1691
+ summaryParams: { days: 2, from: day(21), to: day(22) } as Record<string, string | number> | null,
1692
+ status: 'pending' as string,
1693
+ currentStep: 0,
1694
+ requestedBy: null,
1695
+ requesterPersonId: people[3]!.id,
1696
+ requesterName: people[3]!.displayName,
1697
+ requestedAt: iso(1800_000),
1698
+ decidedAt: null as string | null,
1699
+ steps: [step(id('f004'), 0, 'Manager', [people[2]!.id])] as Array<Record<string, unknown>>,
1700
+ },
1701
+ {
1702
+ id: id('f005'),
1703
+ workspaceId: '',
1704
+ subjectType: 'regularization' as const,
1705
+ subjectId: id('c006'),
1706
+ summary: `Correction for ${WD[3]}`,
1707
+ summaryParams: { date: WD[3]! } as Record<string, string | number> | null,
1708
+ status: 'pending' as string,
1709
+ currentStep: 0,
1710
+ requestedBy: null,
1711
+ requesterPersonId: people[3]!.id,
1712
+ requesterName: people[3]!.displayName,
1713
+ requestedAt: iso(2400_000),
1714
+ decidedAt: null as string | null,
1715
+ steps: [step(id('f005'), 0, 'Manager', [people[2]!.id])] as Array<Record<string, unknown>>,
1716
+ },
1717
+ )
1718
+
1551
1719
  const leaveRequests: Array<Record<string, unknown>> = [
1552
1720
  {
1553
1721
  id: id('c001'),
@@ -3197,7 +3365,10 @@ export function createMockHrApi() {
3197
3365
  requesterName: people.find((x) => x.id === who)?.displayName ?? '',
3198
3366
  requestedAt: iso(),
3199
3367
  decidedAt: null,
3200
- steps: [],
3368
+ // A step, not an empty array: a request raised through the mock has to be decidable the
3369
+ // same way a seeded one is, or the newest row in the inbox is the one that behaves
3370
+ // differently from every other.
3371
+ steps: [step(created.approvalRequestId!, 0, 'Manager', [people[0]!.id])],
3201
3372
  })
3202
3373
  return { ...created, workspaceId: input.workspaceId }
3203
3374
  },
@@ -3579,18 +3750,34 @@ export function createMockHrApi() {
3579
3750
  * A demo whose "Decided" tab is empty looks like a broken filter rather than an empty
3580
3751
  * history, and the two-step request is what shows the step counter at all.
3581
3752
  */
3753
+ /**
3754
+ * Everything waiting on this reader — including what they may decide by delegation — or,
3755
+ * with `status: 'decided'`, everything already settled. The two are exclusive.
3756
+ *
3757
+ * The scope check is the half that is easy to miss: a delegation may be narrower than the
3758
+ * person who granted it, so whether a step is actionable depends on the *request's* subject
3759
+ * type and cannot be answered from the step alone.
3760
+ */
3582
3761
  inbox: async ({
3583
3762
  workspaceId,
3584
- includeDecided = false,
3763
+ status = 'pending',
3764
+ limit = 50,
3585
3765
  }: {
3586
3766
  workspaceId: string
3587
- includeDecided?: boolean
3588
- }) => ({
3589
- items: approvalRequests
3590
- .filter((r) => (includeDecided ? r.status !== 'pending' : r.status === 'pending'))
3591
- .map((r) => ({ ...r, workspaceId })),
3592
- nextCursor: null,
3593
- }),
3767
+ status?: 'pending' | 'decided'
3768
+ limit?: number
3769
+ }) => {
3770
+ const items = approvalRequests
3771
+ .filter((r) => (status === 'pending' ? r.status === 'pending' : r.status !== 'pending'))
3772
+ .filter((r) =>
3773
+ mayDecide(
3774
+ (r.steps as Array<{ approverIds?: string[] }>).flatMap((st) => st.approverIds ?? []),
3775
+ r.subjectType,
3776
+ ),
3777
+ )
3778
+ .map((r) => ({ ...r, workspaceId }))
3779
+ return { items: items.slice(0, limit), nextCursor: null }
3780
+ },
3594
3781
 
3595
3782
  get: async ({ workspaceId, requestId }: { workspaceId: string; requestId: string }) => {
3596
3783
  const found = approvalRequests.find((r) => r.id === requestId)
@@ -3598,17 +3785,71 @@ export function createMockHrApi() {
3598
3785
  return { ...found, workspaceId }
3599
3786
  },
3600
3787
 
3788
+ /**
3789
+ * Records who decided, and in whose place.
3790
+ *
3791
+ * The two ids mean opposite things on the two sides, which is easy to get backwards: on the
3792
+ * way in, `onBehalfOfId` is *whose place you are taking*; on the stored row, `approverId` is
3793
+ * the person the step actually names and `onBehalfOfId` is *whose hands it was*. That is what
3794
+ * lets the decided row read "decided by Ayşe for Sanne" rather than losing one of the two
3795
+ * names. `approvals.ts` does the same swap.
3796
+ */
3601
3797
  decide: async ({
3602
3798
  workspaceId,
3603
3799
  requestId,
3604
3800
  decision,
3801
+ comment = null,
3802
+ onBehalfOfId = null,
3605
3803
  }: {
3606
3804
  workspaceId: string
3607
3805
  requestId: string
3608
3806
  decision: 'approve' | 'reject'
3807
+ comment?: string | null
3808
+ onBehalfOfId?: string | null
3609
3809
  }) => {
3610
3810
  const found = approvalRequests.find((r) => r.id === requestId)
3611
3811
  if (!found) refuse('NOT_FOUND', 'Approval request not found')
3812
+ const me = people[0]!.id
3813
+ const actingAs = onBehalfOfId ?? me
3814
+ const current = found.steps[found.currentStep] as
3815
+ | { id: string; approverIds?: string[]; status?: string; decisions?: unknown[] }
3816
+ | undefined
3817
+ // The same two refusals the service raises, in its words: being on the step is not the same
3818
+ // question as holding a delegation from somebody who is.
3819
+ if (current?.approverIds && !current.approverIds.includes(actingAs)) {
3820
+ refuse('FORBIDDEN', 'You are not an approver on this step')
3821
+ }
3822
+ if (
3823
+ onBehalfOfId &&
3824
+ !delegations.some(
3825
+ (d) =>
3826
+ d.fromPersonId === onBehalfOfId &&
3827
+ d.toPersonId === me &&
3828
+ // Null is the wildcard — a delegation scoped to another subject type does not cover
3829
+ // this request, and one scoped to nothing covers everything. `mayActFor` matches the
3830
+ // null explicitly for the same reason.
3831
+ (d.subjectType === null || d.subjectType === found.subjectType) &&
3832
+ String(d.startsOn) <= day(0) &&
3833
+ String(d.endsOn) >= day(0),
3834
+ )
3835
+ ) {
3836
+ refuse('FORBIDDEN', 'You do not hold a delegation from that person for this')
3837
+ }
3838
+ if (current) {
3839
+ current.decisions = [
3840
+ ...(current.decisions ?? []),
3841
+ {
3842
+ id: crypto.randomUUID(),
3843
+ stepId: current.id,
3844
+ approverId: actingAs,
3845
+ onBehalfOfId: onBehalfOfId ? me : null,
3846
+ decision,
3847
+ comment,
3848
+ at: iso(),
3849
+ },
3850
+ ]
3851
+ current.status = decision === 'approve' ? 'approved' : 'rejected'
3852
+ }
3612
3853
  // A middle step advances rather than settling: the inbox has to be able to show that.
3613
3854
  const last = found.currentStep >= Math.max(found.steps.length - 1, 0)
3614
3855
  if (decision === 'reject' || last) found.status = decision === 'approve' ? 'approved' : 'rejected'