@happyvertical/smrt-human-resources 0.0.0-stage → 0.53.6

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 (64) hide show
  1. package/AGENTS.md +297 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +291 -2
  5. package/dist/__smrt-register__.d.ts +2 -0
  6. package/dist/__smrt-register__.d.ts.map +1 -0
  7. package/dist/dates.d.ts +25 -0
  8. package/dist/dates.d.ts.map +1 -0
  9. package/dist/employment/index.d.ts +3 -0
  10. package/dist/employment/index.d.ts.map +1 -0
  11. package/dist/employment/models.d.ts +73 -0
  12. package/dist/employment/models.d.ts.map +1 -0
  13. package/dist/employment/queries.d.ts +12 -0
  14. package/dist/employment/queries.d.ts.map +1 -0
  15. package/dist/employment/service.d.ts +277 -0
  16. package/dist/employment/service.d.ts.map +1 -0
  17. package/dist/index.d.ts +18 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +2068 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/manifest.json +2424 -0
  22. package/dist/qualifications/index.d.ts +3 -0
  23. package/dist/qualifications/index.d.ts.map +1 -0
  24. package/dist/qualifications/internal.d.ts +24 -0
  25. package/dist/qualifications/internal.d.ts.map +1 -0
  26. package/dist/qualifications/models.d.ts +77 -0
  27. package/dist/qualifications/models.d.ts.map +1 -0
  28. package/dist/qualifications/service.d.ts +381 -0
  29. package/dist/qualifications/service.d.ts.map +1 -0
  30. package/dist/service-base.d.ts +47 -0
  31. package/dist/service-base.d.ts.map +1 -0
  32. package/dist/smrt-knowledge.json +902 -0
  33. package/dist/svelte/components/EmployeeForm.svelte +369 -0
  34. package/dist/svelte/components/EmployeeForm.svelte.d.ts +28 -0
  35. package/dist/svelte/components/EmployeeForm.svelte.d.ts.map +1 -0
  36. package/dist/svelte/components/EmployeeList.svelte +158 -0
  37. package/dist/svelte/components/EmployeeList.svelte.d.ts +13 -0
  38. package/dist/svelte/components/EmployeeList.svelte.d.ts.map +1 -0
  39. package/dist/svelte/components/ExpiringQualificationsList.svelte +126 -0
  40. package/dist/svelte/components/ExpiringQualificationsList.svelte.d.ts +13 -0
  41. package/dist/svelte/components/ExpiringQualificationsList.svelte.d.ts.map +1 -0
  42. package/dist/svelte/components/PersonQualifications.svelte +156 -0
  43. package/dist/svelte/components/PersonQualifications.svelte.d.ts +15 -0
  44. package/dist/svelte/components/PersonQualifications.svelte.d.ts.map +1 -0
  45. package/dist/svelte/i18n.d.ts +67 -0
  46. package/dist/svelte/i18n.d.ts.map +1 -0
  47. package/dist/svelte/i18n.js +67 -0
  48. package/dist/svelte/index.d.ts +23 -0
  49. package/dist/svelte/index.d.ts.map +1 -0
  50. package/dist/svelte/index.js +25 -0
  51. package/dist/svelte/types.d.ts +225 -0
  52. package/dist/svelte/types.d.ts.map +1 -0
  53. package/dist/svelte/types.js +257 -0
  54. package/dist/types.d.ts +119 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +23 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/ui.d.ts +17 -0
  59. package/dist/ui.d.ts.map +1 -0
  60. package/dist/ui.js +65 -0
  61. package/dist/ui.js.map +1 -0
  62. package/dist/write.d.ts +22 -0
  63. package/dist/write.d.ts.map +1 -0
  64. package/package.json +86 -3
package/AGENTS.md ADDED
@@ -0,0 +1,297 @@
1
+ # smrt-human-resources
2
+
3
+ HR records: who works for an organization, as what, and with what standing
4
+ (#3435). It sits between identity (`smrt-profiles`) and pay (payroll, #3292):
5
+ payroll → human-resources → profiles. It holds no pay, no time records, no
6
+ logins or permissions, and no identity or contact details.
7
+
8
+ ## Models
9
+
10
+ | Model | Table | Notes |
11
+ |---|---|---|
12
+ | `Employment` | `employments` | One per tenant and profile. `profileId`, optional `userId` and `employerProfileId`, `employeeNumber` (unique per tenant), `workerType`, `position`, `status` (`active` \| `on-leave` \| `ended`) |
13
+ | `EmploymentTerm` | `employment_terms` | One continuous period: `startedOn`, `endedOn` (last day, inclusive; null is open), `endReason`. A rehire adds a term |
14
+ | `EmploymentChange` | `employment_changes` | Append-only dated change: `kind`, `effectiveOn`, `fromValue`, `toValue`, `actorProfileId` |
15
+ | `Qualification` | `qualifications` | Definition, `key` unique per tenant: `kind`, `issuingBody`, `expires`, `validityMonths`, `scope` (`person` \| `employment`) |
16
+ | `HeldQualification` | `held_qualifications` | A person holds a qualification: `issuedOn`, `expiresOn`, `certificateNumber`, `status`, verifier, optional `documentAssetId`, `renewalOfId` |
17
+ | `HeldQualificationChange` | `held_qualification_changes` | Append-only dated change: `kind`, `effectiveOn`, `reason`, `actorProfileId` |
18
+
19
+ `EmploymentService` and `QualificationService` are the only writers (an
20
+ application subclass may save its own added fields; see "Extending
21
+ Employment"). Both are
22
+ constructed from a trusted `{ tenantId, profileId }` actor the application has
23
+ already authorized, and both take an optional `onEvent` hook. Construct them
24
+ with the root database handle, never one that is already inside a transaction
25
+ (see "Events need the root handle").
26
+
27
+ ## Invariants
28
+
29
+ - **Sensitive and closed.** Every model is `@TenantScoped({ mode: 'required' })`,
30
+ `sensitive: true`, with `api` / `mcp` / `cli` set to `{ include: [] }`.
31
+ `save()` throws outside a service (`src/write.ts`) and `delete()` always
32
+ throws. Services insert rows only through `insertHr()` in `src/write.ts`,
33
+ which uses the model's public constructor, `initialize()` and
34
+ `requireInsertOnSave()`; never reach into a collection's protected
35
+ `createUnsaved()`. A subclass must restate the closed surface: `@smrt` exposure is not
36
+ inherited, and an empty `@smrt()` exposes everything.
37
+ - **Collections are closed too.** Collection classes are registered like
38
+ models, so each of the six carries
39
+ `@smrt({ api: { include: [] }, mcp: { include: [] }, cli: false })` as an
40
+ inline literal (the scanner cannot resolve an imported constant). An
41
+ undecorated collection publishes its methods as MCP tools. A collection's
42
+ config merges over its model's, so a model's effective `cli` reads `false`.
43
+ `src/__tests__/closed-surface.test.ts` asserts no generated tool exists.
44
+ - **The tenant is the employer.** `employerProfileId` is informational and
45
+ never part of uniqueness. There is one `Employment` per tenant and profile,
46
+ so (tenant, profile, date) resolves to at most one employment.
47
+ - **The employment id is stable.** Rehire adds a term to the same row; other
48
+ packages and applications key on `Employment.id`.
49
+ - **Dates are calendar dates.** `YYYY-MM-DD` text, compared as strings, with
50
+ no timezone. The application converts an instant to its local date first.
51
+ `verifiedAt` is the one instant.
52
+ - **"Employed on a date" comes from terms**, never from `status`. `on-leave`
53
+ still counts as employed.
54
+ - **`ended` means an end has been recorded**, not "no longer employed today".
55
+ `end` accepts an `endedOn` ahead of the day it is called (a notice period)
56
+ and stores `ended` at once; the package has no clock and never flips a
57
+ status when a date passes. For "on a date" use `check`, `employedOn`, `asOf`
58
+ or `findByUser(userId, on)`.
59
+ - **Dated changes belong to the latest term.** `changePosition`,
60
+ `changeWorkerType`, `placeOnLeave` and `returnFromLeave` are accepted when
61
+ `effectiveOn` is inside the latest term: the open one, or after an end is
62
+ recorded the final one up to and including its `endedOn`. On an ended
63
+ employment the stored status stays `ended`, the row's `position` /
64
+ `workerType` update as usual, leave is validated from the replayed history
65
+ as of `effectiveOn` (not from `status`), and the usual event is raised. A
66
+ date after the last day is `HR_STATUS_TRANSITION`; before the term started,
67
+ `HR_INVALID`.
68
+ - **History is append-only.** Changes are new rows. A term is closed once. A
69
+ renewal is a new `HeldQualification` pointing at the old one through
70
+ `renewalOfId`. A row stored `revoked` is never saved again, except for its
71
+ verification fields (`HeldQualification.save()` allows a save that changes
72
+ only `verifiedByProfileId`, `verifiedAt` and `documentAssetId`); a later
73
+ suspension, reinstatement or earlier revocation of one is a further change
74
+ row, never an edit.
75
+ - **A stored qualification status is "the last change recorded", not
76
+ standing, and no rule decides standing from it.** `revoke` and `suspend`
77
+ store their status at once even when `effectiveOn` is ahead, and `reinstate` stores `valid`
78
+ even when it takes effect later (`expired` if the sweep has recorded the
79
+ row's lapse).
80
+ The one sticky value is `revoked`: once stored it stays, whatever is
81
+ recorded afterwards. Every rule decides from the dated history, as `check`
82
+ does: the one-live-grant rule, renewal validation, `expiringWithin`, the
83
+ employment-end cutoff, and the transitions themselves.
84
+ - **Transitions are validated by replay as of `effectiveOn`.** On the latest
85
+ row of a chain, from the chain's whole history: `suspend` needs the chain
86
+ neither suspended nor revoked on `effectiveOn`; `reinstate` needs it
87
+ suspended (and not revoked) on `effectiveOn`; both are `HR_INVALID` when
88
+ dated before the chain's latest suspension or reinstatement, so those two
89
+ are recorded in date order. `revoke` is exempt from that ordering (it is
90
+ terminal and the earliest one wins in replay) and is refused only when a
91
+ revocation effective on or before `effectiveOn` is already recorded
92
+ (`HR_STATUS_TRANSITION`). So a revocation recorded for a later date (a
93
+ scheduled one, an employment end with a last day ahead, a grant against a
94
+ closed term) freezes nothing: until it takes effect the qualification can
95
+ be suspended, reinstated, renewed, or revoked from an earlier day. A stored
96
+ `expired` blocks none of them.
97
+ - **A term holds its own changes.** `end` rejects an `endedOn` earlier than a
98
+ position, worker-type or leave change recorded in the open term, and
99
+ `rehire` a start before one, so replay (`check().onLeave`, `asOf`) never
100
+ picks up a change dated outside its term. Login links are not replayed and
101
+ are not part of this rule.
102
+ - **Login links are immediate.** `linkLogin` / `unlinkLogin` write the stored
103
+ `userId` at once; `effectiveOn` is only recorded on the change row. `asOf`
104
+ does not replay them and `findByUser(userId, on)` reads the stored `userId`
105
+ (only the terms are dated).
106
+ - **A login is exclusive to the employment that stores it.** `hire`, `rehire`
107
+ and `linkLogin` reject (`HR_INVALID`) a `userId` stored on any other
108
+ employment in the tenant, whatever that employment's status: an ended
109
+ employment keeps its login through its notice period and after, until
110
+ `unlinkLogin` (which works on an ended employment) clears it. So two
111
+ employments never share a login through the service, and neither lookup
112
+ can be ambiguous; `findByUser` still fails closed on an ambiguous match as
113
+ a backstop for rows written some other way. `findByUser(userId)` goes by
114
+ the stored status; `findByUser(userId, on)` goes by terms covering `on`,
115
+ whatever the status, and is what a host that records end dates ahead of
116
+ time must call.
117
+ - **A renewal chain shares its standing.** `check`, `holders` and
118
+ `listForProfile` treat a row as not good on a date when a later row in its
119
+ chain is suspended or revoked on that date. `suspend`, `reinstate` and
120
+ `revoke` act on the latest row only (`HR_INVALID` on a row that was
121
+ renewed), and the change belongs to the chain: `effectiveOn` may be any
122
+ date from the chain's first `issuedOn` onward (see "Transitions are
123
+ validated by replay" for the ordering rule). It may precede the latest
124
+ row's own `issuedOn`. That is how a ticket in force today is withdrawn
125
+ after its renewal was recorded ahead of time: the earlier row stops passing from
126
+ `effectiveOn`, and the renewal does not restore standing when its issue
127
+ date arrives (revoked from issue, or suspended from issue until
128
+ reinstated). `listForProfile` reports the status of the latest row issued
129
+ by the date, so it agrees with `check` instead of answering
130
+ `not-yet-issued`.
131
+ - **A renewal is validated by date.** `renew` rejects an `issuedOn` earlier
132
+ than the row's own, or earlier than its latest suspension or reinstatement
133
+ (`HR_INVALID`: the renewal would answer for dates the history says were
134
+ suspended); a row suspended on `issuedOn` by replay
135
+ (`HR_STATUS_TRANSITION`: reinstate first); a row revoked on or before
136
+ `issuedOn` (`HR_INVALID`); an expiry earlier than the row's own, or any
137
+ expiry on a renewal of a row that has none (`HR_INVALID`: the renewed row
138
+ keeps answering for its own period, so a shorter renewal would lapse while
139
+ `check` still passed; to replace a qualification with a shorter one,
140
+ revoke it from that day and grant a new one); and a person who holds
141
+ another chain that is not
142
+ revoked on or before `issuedOn` and overlaps the renewal
143
+ (`HR_ALREADY_HELD`). A chain that only starts on or after the day the
144
+ renewal is cut off (its carried revocation) does not overlap, so a lapsed
145
+ ticket can be renewed up to its scheduled revocation even when the next
146
+ grant, dated from that day, was recorded first.
147
+ - **A renewal carries a pending cutoff forward.** A row whose revocation
148
+ takes effect after `issuedOn` is still in force and is renewed normally;
149
+ replay only propagates standing from later rows to earlier ones, so the
150
+ renewal records the cutoff on itself: a `revoked` change at the earlier of
151
+ the old row's pending revocation (with its reason) and, inside a closed
152
+ employment term, `endedOn + 1` (`employment-ended`; it wins a tie). The
153
+ renewal is stored `revoked` and raises `renewed` then `revoked`. Each
154
+ remaining latest row therefore holds its chain's revocation, which
155
+ `expiringWithin`, the one-live-grant rule and `renew` rely on.
156
+ - **One live grant, by date.** `grant` is refused (`HR_ALREADY_HELD`) unless
157
+ every chain the person already holds of that qualification is revoked
158
+ effective on or before the new `issuedOn`. A revocation recorded for a
159
+ later date does not free an earlier issue date (the message says to renew
160
+ the existing one), and a lapsed ticket, swept or not, is renewed instead of
161
+ granted again.
162
+ - **Verification is not a status.** `verify` sets `verifiedByProfileId` and
163
+ `verifiedAt` (and optionally the document) on any row, including one stored
164
+ `revoked`; it writes no change row and raises no event.
165
+ - **Ids are validated.** A blank or missing id is `HR_INVALID` in both
166
+ services (`HrService.id`); the `check` methods answer `not-employed` /
167
+ `not-held` instead.
168
+ - **Qualification scope.** A `person`-scoped held qualification survives a
169
+ change of employment. An `employment`-scoped one carries `employmentId` and
170
+ is revoked, with reason `employment-ended`, in the transaction that ends the
171
+ employment. The revocation is effective the day after `endedOn`, so the
172
+ qualification is still good on the last day employed. The cutoff is the
173
+ earliest one, decided by date and per renewal chain
174
+ (`revokeEmploymentQualifications`): the latest row of each chain of the
175
+ employment gets it, with one `held-qualification.revoked` event, unless a
176
+ revocation effective on or before it is already recorded anywhere in the
177
+ chain. Rows that were renewed are not written; replay cuts them off through
178
+ the chain. A latest row already stored `revoked` for a later date gets the
179
+ change row only and the event carries the earlier date; replay takes the
180
+ earliest revocation. A grant or renewal dated inside a term whose end is
181
+ already recorded (granted during notice, or backdated) gets the same
182
+ revocation in its own transaction and is stored `revoked` from the start.
183
+ - **Expiry is computed.** A check on a date reads `expiresOn`; the stored
184
+ `expired` status is written by `QualificationService.sweepExpired()`,
185
+ which the application calls from its own scheduler (and by reinstating a
186
+ row whose lapse the sweep has recorded); it is bookkeeping and
187
+ no rule reads standing from it. The sweep decides which rows lapsed by
188
+ date: every row whose last day is before `today`, that has no `expired`
189
+ change yet and was not revoked on or before the day after `expiresOn`,
190
+ gets an `expired` change and (unless it was renewed) the event. That
191
+ includes a ticket that runs out while suspended, or while a revocation is
192
+ recorded for a later day (a notice period). Only a row stored `valid` has
193
+ its stored status set to `expired`; a stored `suspended` or `revoked`
194
+ stays. Those rows are re-read on every run, so a scheduled sweep should
195
+ pass `{ since }` (a day on or before the last day its previous run
196
+ covered) to bound the work. `expiringWithin(days, today,
197
+ { employedOnly })` lists rows that are good on `today` by the same dated
198
+ evaluation as `check` (issued by `today`, not suspended or revoked on it,
199
+ not renewed) and can keep only people employed on `today`.
200
+ - **Events need the root handle.** A mutation begins its own transaction
201
+ with `db.beginTransaction()`, commits it, then delivers its events.
202
+ `@happyvertical/sql` has no after-commit hook and no marker for "inside a
203
+ transaction" (happyvertical/sdk#1249), so on a handle that is already
204
+ inside one (the `tx` of `db.transaction()`, a savepoint scope, or a
205
+ `beginTransaction()` handle) the commit would be the caller's and an outer
206
+ rollback could follow a delivered event. `HrService.transact` therefore
207
+ refuses a mutation on such a handle with `HR_TRANSACTION_UNSUPPORTED`
208
+ before writing anything. It recognises one by asking it to begin: a
209
+ transaction-scoped handle either has no `beginTransaction` (libsql SQLite,
210
+ PostgreSQL, DuckDB, JSON) or exposes one that throws
211
+ `NestedTransactionError` without touching the connection (the
212
+ native-capabilities SQLite adapter, opened for `vector` /
213
+ `notifications`). Never go back to `db.transaction()` /
214
+ `withEmbeddedWriteTransaction` here: on a transaction-scoped handle those
215
+ re-enter the caller's transaction under a savepoint, or with
216
+ `reuseUnsupportedNested` run on the caller's handle, and both fail open.
217
+ Reads work on any handle. Tests that isolate with
218
+ `createIsolatedTestDb*()` must hand the services `baseDb`, not the
219
+ transaction handle.
220
+ - **Tested on SQLite and PostgreSQL.** `HrService.identity` normalizes ids
221
+ only on PostgreSQL. On any other adapter (DuckDB included) nothing is
222
+ normalized, so callers must pass canonical lowercase UUIDs.
223
+ - **Not a permission system.** A qualification says what a person may do in
224
+ the real world. Who may use the software stays with `smrt-users`.
225
+ - **People are `smrt-profiles:Profile` ids** in fields named `profileId` or
226
+ `<role>ProfileId`, as cross-package refs. This is the id `smrt-timesheets`
227
+ holds.
228
+
229
+ ## Extending Employment
230
+
231
+ Declare a same-named subclass over the `employments` table in the application,
232
+ restating the fields and the closed surface, and add application fields there
233
+ (teamworks-os keeps succession rank and acting-role fields this way). Only one
234
+ inheritance family may share a table; the deepest same-named subclass wins.
235
+
236
+ `Employment.save()` lets such a subclass write its own fields: without the
237
+ service's write capability a save is allowed only for an existing row whose
238
+ package-owned columns (`tenantId`, `profileId`, `userId`, `employerProfileId`,
239
+ `employeeNumber`, `workerType`, `position`, `status`) all still hold their
240
+ stored values. A save that changes one of them, and any insert, throws
241
+ `HR_WRITE_FORBIDDEN`; `delete()` always throws. Add a column to
242
+ `PACKAGE_OWNED_COLUMNS` in `src/employment/models.ts` when the package gains
243
+ one.
244
+
245
+ ## Svelte UI
246
+
247
+ `@happyvertical/smrt-human-resources/svelte` ships four components, each
248
+ registered as a slot in `HUMAN_RESOURCES_UI_SLOTS` (`src/ui.ts`):
249
+
250
+ | Component | Slot | Shows |
251
+ |---|---|---|
252
+ | `EmployeeList` | `employee-list` | Name, employee number, position, worker type, status (plus "Last day {date}" when `endsOn` is set), start date |
253
+ | `EmployeeForm` | `employee-form` | Employee number, worker type, position, start date (new hire only), effective date (edit only), optional login |
254
+ | `PersonQualifications` | `person-qualifications` | One person's qualifications with their expiry state |
255
+ | `ExpiringQualificationsList` | `expiring-qualifications-list` | Qualifications expiring soon across people, soonest first |
256
+
257
+ - **Props-driven.** Components never load or save. The host calls the
258
+ services, adapts rows with the helpers in `src/svelte/types.ts`
259
+ (`toEmployeeView`, `toPersonQualificationView`,
260
+ `toExpiringQualificationView`) and handles `onselect` / `onsubmit`.
261
+ - **No clock in components.** `EmployeeList` shows the stored status badge
262
+ and, when the host passes `endsOn` (the latest term's `endedOn`, fourth
263
+ argument of `toEmployeeView`), the text "Last day {date}". It never compares
264
+ a date with today to decide a label.
265
+ - **Names come from the host.** HR holds no identity data, so every view
266
+ carries a `displayName` the host supplies.
267
+ - **`EmployeeForm` is hand-built** from smrt-ui form primitives (owner
268
+ decision); the package does not depend on `smrt-fields` or `smrt-users`.
269
+ - **`EmployeeForm` values are not a service input.** A hire returns
270
+ `startedOn`; an edit returns a required `effectiveOn` (prefilled from
271
+ `today`) that the host passes to `changePosition` / `changeWorkerType` /
272
+ `linkLogin` / `unlinkLogin` for each value that changed. A login link or
273
+ unlink takes effect at once whatever the date, so a future-dated edit that
274
+ also clears the login unlinks it immediately; a host that wants it kept
275
+ until then calls `unlinkLogin` on that day. The employee's
276
+ current login stays an option even when the host's `logins` omit it, so a
277
+ save never unlinks it by accident. Each validation message is tied to its
278
+ field with `aria-describedby`.
279
+ - **Day arithmetic is UTC** (`daysUntil`); never build a local-time `Date`
280
+ from a calendar date.
281
+ - **Expiry state is text plus a badge**, never colour alone. "Expiring soon"
282
+ is a valid qualification whose last day is within `expiringSoonDays`
283
+ (default 30), inclusive.
284
+ - **Strings** go through `useI18n()` and the catalog in `src/svelte/i18n.ts`,
285
+ keyed `human_resources.<component>.<key>`. Only smrt-ui primitives; no raw
286
+ `<input>` / `<button>` / `<select>`.
287
+
288
+ ## Validation commands
289
+
290
+ ```bash
291
+ pnpm --filter @happyvertical/smrt-human-resources build
292
+ pnpm --filter @happyvertical/smrt-human-resources test
293
+ pnpm --filter @happyvertical/smrt-human-resources typecheck
294
+ ```
295
+
296
+ Build before `test`: `src/__tests__/employment-extension.test.ts` reads
297
+ `dist/manifest.json`.
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,292 @@
1
- # Temporary Holding Version
1
+ # @happyvertical/smrt-human-resources
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ HR records for s-m-r-t: who works for an organization, as what, and
4
+ with what standing.
5
+
6
+ - **Employment**: one record per tenant and person, with an employee number,
7
+ worker type, position, status, dated terms and an append-only change history.
8
+ - **Qualifications**: a managed list of definitions (tickets, certifications,
9
+ authorizations, training, restrictions) and what each person holds, with
10
+ issue and expiry dates.
11
+
12
+ Pay belongs to payroll, time records to `smrt-timesheets`, logins and
13
+ permissions to `smrt-users`, and identity to `smrt-profiles`.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @happyvertical/smrt-human-resources
19
+ ```
20
+
21
+ ## Access
22
+
23
+ Every model is tenant-scoped (required), sensitive, and publishes no generated
24
+ REST, MCP or CLI operation; the collection classes are closed the same way.
25
+ All writes go through `EmploymentService` and
26
+ `QualificationService`. The application authorizes the acting person first,
27
+ then constructs a service from that trusted actor:
28
+
29
+ ```typescript
30
+ const actor = { tenantId, profileId: managerProfileId };
31
+ ```
32
+
33
+ `profileId` is a `smrt-profiles` Profile id and is recorded on every change.
34
+ Models reject `save()` and `delete()` outside the services (an application
35
+ subclass of `Employment` may save its own fields; see below).
36
+
37
+ ## Dates
38
+
39
+ Dates are calendar dates written `YYYY-MM-DD`, with no timezone. Convert an
40
+ instant to your local date before asking "on what day". An employment term's
41
+ `endedOn` is the last day employed, inclusive.
42
+
43
+ ## Employment
44
+
45
+ ```typescript
46
+ import { EmploymentService } from '@happyvertical/smrt-human-resources';
47
+
48
+ const hr = new EmploymentService(
49
+ db,
50
+ { tenantId, profileId: managerProfileId },
51
+ { onEvent: (event) => void notified.push(event.type) },
52
+ );
53
+
54
+ const employment = await hr.hire({
55
+ profileId: workerProfileId,
56
+ employeeNumber: 'E-1042',
57
+ startedOn: '2026-01-05',
58
+ position: 'Welder',
59
+ });
60
+
61
+ const check = await hr.check(workerProfileId, '2026-02-01');
62
+ // { ok: true, employmentId: employment.id, onLeave: false }
63
+
64
+ await hr.end(employment.id as string, {
65
+ endedOn: '2026-03-31', // last day employed, inclusive
66
+ reason: 'Season over',
67
+ });
68
+ const after = await hr.check(workerProfileId, '2026-04-01');
69
+ // { ok: false, reason: 'ended' }
70
+
71
+ const rehired = await hr.rehire(employment.id as string, {
72
+ startedOn: '2026-06-01',
73
+ });
74
+ // rehired.id === employment.id; hr.terms(rehired.id) has two terms
75
+ ```
76
+
77
+ - An end may be recorded ahead of the last day (a notice period). The stored
78
+ status becomes `ended` at once and means "an end has been recorded", not
79
+ "no longer employed today": the person is employed up to and including
80
+ `endedOn`. Ask "on a date" with `check(profileId, on)`, `employedOn(on)`,
81
+ `asOf(employmentId, on)` or `findByUser(userId, on)`. The package has no
82
+ clock and never flips a status when a date passes.
83
+ - There is one employment per tenant and profile. A rehire adds a term to the
84
+ same record, so its id is stable for anything that references it.
85
+ - `check(profileId, on)` and `employedOn(on)` answer from the dated terms, not
86
+ from the stored status. Someone on leave is still employed; `check` reports
87
+ `onLeave`.
88
+ - `changePosition`, `changeWorkerType`, `placeOnLeave` and `returnFromLeave`
89
+ are dated changes; `history()` returns them and `asOf(employmentId, on)`
90
+ replays them for a past date.
91
+ - `linkLogin` and `unlinkLogin` are recorded in `history()` with their
92
+ `effectiveOn`, but they are not replayed and not scheduled: the login is
93
+ linked or cleared as soon as the call is made, whatever the date says, and
94
+ `findByUser(userId, on)` reads the login stored now. To unlink on a future
95
+ day, call `unlinkLogin` on that day.
96
+ - Position, worker-type and leave changes are accepted inside the latest
97
+ term. After an end is recorded that is the final term, up to and including
98
+ its `endedOn`: the change is recorded and raises its usual event, the stored
99
+ status stays `ended`, and leave is validated against the replayed history
100
+ (`check().onLeave` and `asOf` answer it). A date after the last day is
101
+ rejected with `HR_STATUS_TRANSITION` (rehire first); a date before the term
102
+ started with `HR_INVALID`.
103
+ - A term holds its own changes: `end` rejects an `endedOn` earlier than a
104
+ position, worker-type or leave change already recorded in the term, and
105
+ `rehire` rejects a start before one.
106
+ - The login link (`userId`) is optional, and a login belongs to one
107
+ employment at a time. It stays with that employment, ended or not, until
108
+ `unlinkLogin` clears it there: `hire`, `rehire` and `linkLogin` reject
109
+ (`HR_INVALID`) a login stored on any other employment, so a worker on
110
+ notice keeps their login and nobody else can take it. To move a login,
111
+ unlink it first (`unlinkLogin` works on an ended employment).
112
+ `findByUser(userId)` returns only an employment whose
113
+ stored status is not `ended`, so it stops resolving as soon as an end is
114
+ recorded. `findByUser(userId, on)` answers from the terms instead: the
115
+ linked employment with a term covering `on`, whatever its status. A host
116
+ that records end dates ahead of time should pass the date, or a worker on
117
+ notice is locked out.
118
+
119
+ ## Qualifications
120
+
121
+ ```typescript
122
+ import { QualificationService } from '@happyvertical/smrt-human-resources';
123
+
124
+ const qualifications = new QualificationService(db, actor, {
125
+ onEvent: (event) => void events.push(event), // after commit; a throwing handler never rolls back
126
+ });
127
+ const firstAidTicket = await qualifications.define({
128
+ key: 'first-aid',
129
+ name: 'First aid',
130
+ kind: 'certification',
131
+ expires: true,
132
+ validityMonths: 36,
133
+ });
134
+ const held = await qualifications.grant({
135
+ qualificationId: firstAidTicket.id as string,
136
+ profileId: workerProfileId,
137
+ issuedOn: '2026-03-01', // expiresOn defaults to 2029-03-01
138
+ certificateNumber: 'FA-20931',
139
+ verified: true,
140
+ });
141
+ const gate = await qualifications.check(
142
+ workerProfileId,
143
+ firstAidTicket.id as string,
144
+ '2029-03-02',
145
+ ); // { ok: false, reason: 'expired' }
146
+ const renewal = await qualifications.renew(held.id as string, {
147
+ issuedOn: '2029-02-15',
148
+ }); // a new row; `held` still answers for 2026-03-01..2029-03-01
149
+ const after = await qualifications.check(
150
+ workerProfileId,
151
+ firstAidTicket.id as string,
152
+ '2029-03-02',
153
+ ); // { ok: true, heldQualificationId: renewal.id, expiresOn: '2032-02-15' }
154
+ ```
155
+
156
+ - `check()` is the gate an application calls before allowing work. When it
157
+ fails it says why: `not-held`, `not-yet-issued`, `expired`, `suspended` or
158
+ `revoked`.
159
+ - Expiry is computed from the dates, so a check needs no sweep.
160
+ - Standing is decided by date everywhere. `suspend`, `reinstate` and `revoke`
161
+ take an `effectiveOn` that may be ahead; the stored `status` changes at
162
+ once and only says what was last recorded. `check`, `holders`,
163
+ `listForProfile` and `expiringWithin` all answer from the dated history,
164
+ and so do the rules for the next change: `suspend` needs the qualification
165
+ to be neither suspended nor revoked on `effectiveOn`, `reinstate` needs it
166
+ suspended on that day, and neither may be dated before the latest
167
+ suspension or reinstatement.
168
+ - `revoke` is accepted for any date from the first issue date unless the
169
+ qualification is already revoked on or before it. A revocation recorded for
170
+ a later date (scheduled, or the end of an employment whose last day is
171
+ ahead) freezes nothing: until it takes effect the qualification can still
172
+ be suspended, reinstated, renewed, or revoked from an earlier day, and the
173
+ earliest revocation is the one in force. A stored `revoked` is final, so
174
+ those later changes are history rows only and the status keeps reading
175
+ `revoked`.
176
+ - A person has one live grant of a qualification at a time. `grant` is
177
+ refused (`HR_ALREADY_HELD`) unless everything they already hold of it is
178
+ revoked effective on or before the new `issuedOn`: a revocation scheduled
179
+ for 2027 does not allow a fresh grant dated 2026 (renew the existing one
180
+ instead), and a lapsed ticket is renewed, not granted again.
181
+ - A definition's `scope` says who a held qualification belongs to. A `person`
182
+ qualification survives a change of employment. An `employment` one is
183
+ revoked, with reason `employment-ended`, when that employment ends: it is
184
+ still good on the last day employed and revoked from the day after. That
185
+ cutoff always wins: it also applies to a qualification whose own revocation
186
+ was scheduled for a later date, and to one granted or renewed after the end
187
+ was recorded (during notice, or backdated), which is stored `revoked` at
188
+ once and passes `check` only through the last day employed. The end is
189
+ decided per renewal chain: the latest row gets one revocation and one
190
+ `held-qualification.revoked` event, the rows it renewed are cut off through
191
+ the chain, and a chain already revoked on or before the cutoff gets nothing.
192
+ - A renewal is a new row, and the chain shares its standing. Suspend, reinstate
193
+ or revoke the latest row: while it is suspended or revoked, the rows it
194
+ renewed do not pass `check` either, and acting on a row that was already
195
+ renewed is rejected (`HR_INVALID`). The change may take effect on any date
196
+ from the chain's first issue date, so a ticket in force today can still be
197
+ suspended or revoked after its renewal was recorded ahead of time: act on
198
+ the renewal with today's date, and the renewal does not restore standing
199
+ when its own issue date arrives.
200
+ - A renewal cannot end before what it renews (`HR_INVALID`): the renewed row
201
+ keeps answering for its own period, so a shorter renewal would lapse while
202
+ `check` still passed. To replace a qualification with a shorter one, revoke
203
+ it from that day and grant a new one.
204
+ - A renewal cannot be issued before the latest suspension or reinstatement
205
+ of the row it renews (`HR_INVALID`), nor while that row is suspended on the
206
+ issue date (`HR_STATUS_TRANSITION`: reinstate it first), nor when the row
207
+ is revoked on or before the issue date (`HR_INVALID`; grant a new one dated
208
+ on or after the revocation). A row whose revocation takes effect later is
209
+ still in force and can be renewed, so a ticket that lapses during a notice
210
+ period is not stuck: the renewal carries the cutoff forward (the pending
211
+ revocation, or the day after the last day employed if that is earlier), is
212
+ stored `revoked`, and raises `held-qualification.renewed` and `.revoked`.
213
+ - `verify(heldQualificationId, { documentAssetId })` records the actor as
214
+ having verified the document, now, and optionally attaches or replaces it.
215
+ It changes no status and works whatever the standing, including on a row
216
+ stored `revoked`.
217
+ - `holders(qualificationId, on, { employedOnly: true })` lists, for example,
218
+ the first aiders currently employed. `expiringWithin(days, today)` lists
219
+ what is about to lapse: qualifications that pass `check` on `today` (issued,
220
+ not suspended or revoked on that day, not already renewed) and whose last
221
+ day is within the window; `{ employedOnly: true }` keeps only people
222
+ employed on `today`.
223
+ - A blank id is rejected with `HR_INVALID`; `check` answers `not-held`.
224
+ - `seed(SUGGESTED_QUALIFICATIONS)` creates a few common definitions; it never
225
+ changes ones that already exist.
226
+ - A qualification is not a permission. Who may use the software stays with
227
+ `smrt-users`; an application may require both.
228
+
229
+ ## Events and expiry
230
+
231
+ Both services take an `onEvent` hook, called after the change commits:
232
+ `employment.hired`, `.ended`, `.rehired`, `.position-changed`,
233
+ `.worker-type-changed`, `.leave-started`, `.leave-ended`, and
234
+ `held-qualification.granted`, `.renewed`, `.suspended`, `.reinstated`,
235
+ `.revoked`, `.expired`. Delivery is best-effort; bridge it to your own event
236
+ bus if you need more.
237
+
238
+ Construct the services with your root database handle. Each mutation begins
239
+ its own transaction (`beginTransaction()`), commits it, and then delivers its
240
+ events. A service built on a handle that is already inside a transaction (the
241
+ `tx` passed to `db.transaction()`, a savepoint scope, or a
242
+ `beginTransaction()` handle) could only deliver before the outer commit, so
243
+ it refuses every mutation with `HR_TRANSACTION_UNSUPPORTED` and writes
244
+ nothing; reads still work. Such a handle either has no `beginTransaction` or
245
+ refuses it with the SDK's `NestedTransactionError` (the native-capabilities
246
+ SQLite adapter, used for `vector` / `notifications`); the services never open
247
+ a nested transaction. An HR mutation therefore cannot be made atomic with
248
+ other work in a caller's transaction.
249
+
250
+ The package is tested on SQLite and PostgreSQL. Ids are normalized (to
251
+ canonical lowercase UUIDs) only on PostgreSQL; on any other adapter, DuckDB
252
+ included, pass canonical lowercase UUIDs yourself.
253
+
254
+ Nothing in the package runs on a schedule. Call
255
+ `qualifications.sweepExpired(today)` from your own scheduler to record lapsed
256
+ qualifications and receive `held-qualification.expired`. It decides by date,
257
+ so a ticket that runs out while suspended, or during a notice period, is
258
+ recorded too. A scheduled sweep should pass `{ since }`, a day on or before
259
+ the last day its previous run covered, so it does not re-read all history.
260
+ Reminders are the application's business.
261
+
262
+ ## Extending Employment
263
+
264
+ Add application fields with a same-named subclass over the `employments`
265
+ table. Restate the closed surface on the subclass: exposure is not inherited.
266
+ Read your subclass through your own collection and `save()` your own fields on
267
+ a row the service created. The columns this package owns (`tenantId`,
268
+ `profileId`, `userId`, `employerProfileId`, `employeeNumber`, `workerType`,
269
+ `position`, `status`) still change only through `EmploymentService`: a save
270
+ that changes one of them, or that would insert a row, throws
271
+ `HR_WRITE_FORBIDDEN`.
272
+
273
+ ## Svelte components
274
+
275
+ `@happyvertical/smrt-human-resources/svelte` exports props-driven components:
276
+ an employee list, an employee form, a person's qualifications with their
277
+ expiry state, and an expiring-soon list. The host loads records through the
278
+ services and passes plain view objects.
279
+
280
+ `EmployeeList` shows the stored status. Pass the latest term's `endedOn` as
281
+ `endsOn` (the fourth argument of `toEmployeeView`) and the row also reads
282
+ "Last day 2026-12-31", so a worker on notice is not mistaken for one who has
283
+ left. The component never compares the date with today.
284
+
285
+ `EmployeeForm` never saves. For a new hire it returns `startedOn`; for an edit
286
+ it returns a required `effectiveOn` (prefilled from the `today` prop), which
287
+ the host passes to `changePosition`, `changeWorkerType`, `linkLogin` or
288
+ `unlinkLogin` for each value that changed. Position and worker-type changes
289
+ take effect on that date; a login link or unlink takes effect at once, so a
290
+ future-dated edit that also clears the login unlinks it immediately. A host
291
+ that wants the login kept until the date should hold the unlink back and
292
+ call `unlinkLogin` on that day.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=__smrt-register__.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"__smrt-register__.d.ts","sourceRoot":"","sources":["../src/__smrt-register__.ts"],"names":[],"mappings":""}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Calendar-date helpers. HR dates are `YYYY-MM-DD` text, so they compare
3
+ * correctly as strings and carry no timezone; the application converts an
4
+ * instant to its own local date before asking.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ import { type IsoDate } from './types.js';
9
+ /** Whether `value` is a real calendar date written `YYYY-MM-DD`. */
10
+ export declare function isIsoDate(value: unknown): value is IsoDate;
11
+ /** Throw unless `value` is a real calendar date written `YYYY-MM-DD`. */
12
+ export declare function assertIsoDate(fieldName: string, value: unknown): IsoDate;
13
+ /** The calendar date `days` after `date` (negative moves back). */
14
+ export declare function addDays(date: IsoDate, days: number): IsoDate;
15
+ /**
16
+ * The calendar date `months` after `date`, clamped to the end of the target
17
+ * month (31 January + 1 month is the last day of February).
18
+ */
19
+ export declare function addMonths(date: IsoDate, months: number): IsoDate;
20
+ /**
21
+ * Whether `on` falls inside the inclusive range `[start, end]`. A null `end`
22
+ * is open-ended.
23
+ */
24
+ export declare function dateWithin(on: IsoDate, start: IsoDate, end: IsoDate | null): boolean;
25
+ //# sourceMappingURL=dates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dates.d.ts","sourceRoot":"","sources":["../src/dates.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AAInD,oEAAoE;AACpE,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,OAAO,CAW1D;AAED,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAOxE;AAED,mEAAmE;AACnE,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAK5D;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAQhE;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,EAAE,EAAE,OAAO,EACX,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,GAAG,IAAI,GAClB,OAAO,CAET"}
@@ -0,0 +1,3 @@
1
+ export * from './models.js';
2
+ export * from './service.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/employment/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC"}