@kernhq/module-hr 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/router.d.ts +14 -2
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +11 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/jobs.d.ts +5 -2
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +72 -2
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +4 -1
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +1 -1
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +85 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +32 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +150 -6
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +428 -26
- package/dist/server/services/approvals.js.map +1 -1
- package/migrations/0010_approval_timeouts.sql +28 -0
- package/migrations/meta/0010_snapshot.json +4263 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +1 -1
- package/src/client/components/DecisionDialog.svelte +99 -7
- package/src/client/components/PersonPanel.svelte +5 -5
- package/src/client/components/redaction.ts +28 -40
- package/src/client/messages.ts +68 -0
- package/src/client/mock.ts +256 -15
- package/src/client/pages/ApprovalsPage.svelte +256 -27
- package/src/client/pages/DirectoryPage.svelte +10 -7
- package/src/client/permissions.ts +6 -16
- package/src/client/query.ts +2 -2
- package/src/client/widgets/ApprovalsWidget.svelte +1 -1
- package/src/contract/router.ts +11 -2
package/src/client/mock.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
3763
|
+
status = 'pending',
|
|
3764
|
+
limit = 50,
|
|
3585
3765
|
}: {
|
|
3586
3766
|
workspaceId: string
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
.
|
|
3592
|
-
|
|
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'
|