@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/dist/index.js ADDED
@@ -0,0 +1,2068 @@
1
+ import { EMPLOYMENT_STATUS_TRANSITIONS, HrError, SUGGESTED_WORKER_TYPES } from "./types.js";
2
+ import { HUMAN_RESOURCES_MODULE_META, HUMAN_RESOURCES_UI_SLOTS } from "./ui.js";
3
+ import { ObjectRegistry, SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, isEmbeddedDatabase, isPostgresDatabase, smrt, withEmbeddedWriteQueue } from "@happyvertical/smrt-core";
4
+ import { TenantScoped, getTenantId, tenantId, withTenant } from "@happyvertical/smrt-tenancy";
5
+ import { createLogger } from "@happyvertical/logger";
6
+ import { NestedTransactionError } from "@happyvertical/sql";
7
+ //#region src/__smrt-register__.ts
8
+ ObjectRegistry.registerPackageManifest(JSON.parse("{\"version\":\"1.0.0\",\"timestamp\":0,\"packageName\":\"@happyvertical/smrt-human-resources\",\"packageVersion\":\"0.53.6\",\"objects\":{\"@happyvertical/smrt-human-resources:Employment\":{\"name\":\"employment\",\"className\":\"Employment\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:Employment\",\"collection\":\"employments\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/employment/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{\"tenantId\":{\"type\":\"text\",\"required\":true,\"_meta\":{\"sqlType\":\"UUID\",\"__tenancy\":{\"isTenantIdField\":true,\"autoFilter\":true,\"required\":true,\"autoPopulate\":true,\"nullable\":false,\"mode\":\"required\",\"field\":\"tenantId\",\"allowSuperAdminBypass\":false}}},\"profileId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-profiles:Profile\",\"_meta\":{\"onDelete\":\"RESTRICT\"}},\"userId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-users:User\",\"_meta\":{\"nullable\":true}},\"employerProfileId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-profiles:Profile\",\"_meta\":{\"nullable\":true,\"onDelete\":\"RESTRICT\"}},\"employeeNumber\":{\"type\":\"text\",\"required\":false},\"workerType\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"position\":{\"type\":\"text\",\"required\":false,\"_meta\":{\"nullable\":true}},\"status\":{\"type\":\"text\",\"required\":false,\"_meta\":{}}},\"methods\":{\"save\":{\"name\":\"save\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<this>\",\"isStatic\":false,\"isPublic\":true},\"delete\":{\"name\":\"delete\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<void>\",\"isStatic\":false,\"isPublic\":true}},\"decoratorConfig\":{\"tableName\":\"employments\",\"sensitive\":true,\"conflictColumns\":[\"tenant_id\",\"employee_number\"],\"indexes\":[{\"name\":\"employments_tenant_profile_uq\",\"columns\":[\"tenantId\",\"profileId\"],\"unique\":true},{\"name\":\"employments_tenant_user_idx\",\"columns\":[\"tenantId\",\"userId\"]}],\"api\":{\"include\":[]},\"cli\":{\"include\":[]},\"mcp\":{\"include\":[]},\"tenantScoped\":{\"mode\":\"required\"}},\"extends\":\"SmrtObject\",\"exportName\":\"Employment\",\"collectionExportName\":\"EmploymentCollection\",\"validationRules\":[],\"schema\":{\"tableName\":\"employments\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"employments\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"profile_id\\\" UUID,\\n \\\"user_id\\\" UUID,\\n \\\"employer_profile_id\\\" UUID,\\n \\\"employee_number\\\" TEXT,\\n \\\"worker_type\\\" TEXT,\\n \\\"position\\\" TEXT,\\n \\\"status\\\" TEXT\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"user_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"employer_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"employee_number\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"worker_type\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"position\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"status\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"employments_tenant_id_employee_number_idx\",\"columns\":[\"tenant_id\",\"employee_number\"],\"unique\":true,\"nullsNotDistinct\":true},{\"name\":\"employments_slug_context_idx\",\"columns\":[\"slug\",\"context\"]},{\"name\":\"employments_tenant_profile_uq\",\"columns\":[\"tenant_id\",\"profile_id\"],\"unique\":true},{\"name\":\"employments_tenant_user_idx\",\"columns\":[\"tenant_id\",\"user_id\"]},{\"name\":\"employments_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"employments_profile_id_idx\",\"columns\":[\"profile_id\"]},{\"name\":\"employments_user_id_idx\",\"columns\":[\"user_id\"]},{\"name\":\"employments_employer_profile_id_idx\",\"columns\":[\"employer_profile_id\"]}],\"version\":\"ff7ef7a8\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:EmploymentTerm\":{\"name\":\"employmentterm\",\"className\":\"EmploymentTerm\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:EmploymentTerm\",\"collection\":\"employmentterms\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/employment/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{\"tenantId\":{\"type\":\"text\",\"required\":true,\"_meta\":{\"sqlType\":\"UUID\",\"__tenancy\":{\"isTenantIdField\":true,\"autoFilter\":true,\"required\":true,\"autoPopulate\":true,\"nullable\":false,\"mode\":\"required\",\"field\":\"tenantId\",\"allowSuperAdminBypass\":false}}},\"employmentId\":{\"type\":\"foreignKey\",\"required\":false,\"related\":\"Employment\",\"_meta\":{\"onDelete\":\"RESTRICT\"}},\"startedOn\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"endedOn\":{\"type\":\"text\",\"required\":false,\"_meta\":{\"nullable\":true}},\"endReason\":{\"type\":\"text\",\"required\":false}},\"methods\":{\"save\":{\"name\":\"save\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<this>\",\"isStatic\":false,\"isPublic\":true},\"delete\":{\"name\":\"delete\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<void>\",\"isStatic\":false,\"isPublic\":true}},\"decoratorConfig\":{\"tableName\":\"employment_terms\",\"sensitive\":true,\"indexes\":[{\"name\":\"employment_terms_employment_idx\",\"columns\":[\"tenantId\",\"employmentId\",\"startedOn\"]}],\"api\":{\"include\":[]},\"cli\":{\"include\":[]},\"mcp\":{\"include\":[]},\"tenantScoped\":{\"mode\":\"required\"},\"conflictColumns\":[\"tenant_id\",\"slug\",\"context\"]},\"extends\":\"SmrtObject\",\"exportName\":\"EmploymentTerm\",\"collectionExportName\":\"EmploymentTermCollection\",\"validationRules\":[],\"schema\":{\"tableName\":\"employment_terms\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"employment_terms\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"employment_id\\\" UUID,\\n \\\"started_on\\\" TEXT,\\n \\\"ended_on\\\" TEXT,\\n \\\"end_reason\\\" TEXT\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"employment_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"employments\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"started_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"ended_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"end_reason\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"employment_terms_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"employment_terms_employment_idx\",\"columns\":[\"tenant_id\",\"employment_id\",\"started_on\"]},{\"name\":\"employment_terms_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"employment_terms_employment_id_idx\",\"columns\":[\"employment_id\"]}],\"version\":\"165d209b\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:EmploymentChange\":{\"name\":\"employmentchange\",\"className\":\"EmploymentChange\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:EmploymentChange\",\"collection\":\"employmentchanges\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/employment/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{\"tenantId\":{\"type\":\"text\",\"required\":true,\"_meta\":{\"sqlType\":\"UUID\",\"__tenancy\":{\"isTenantIdField\":true,\"autoFilter\":true,\"required\":true,\"autoPopulate\":true,\"nullable\":false,\"mode\":\"required\",\"field\":\"tenantId\",\"allowSuperAdminBypass\":false}}},\"employmentId\":{\"type\":\"foreignKey\",\"required\":false,\"related\":\"Employment\",\"_meta\":{\"onDelete\":\"RESTRICT\"}},\"kind\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"effectiveOn\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"fromValue\":{\"type\":\"text\",\"required\":false,\"_meta\":{\"nullable\":true}},\"toValue\":{\"type\":\"text\",\"required\":false,\"_meta\":{\"nullable\":true}},\"note\":{\"type\":\"text\",\"required\":false},\"actorProfileId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-profiles:Profile\",\"_meta\":{\"onDelete\":\"RESTRICT\"}}},\"methods\":{\"save\":{\"name\":\"save\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<this>\",\"isStatic\":false,\"isPublic\":true},\"delete\":{\"name\":\"delete\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<void>\",\"isStatic\":false,\"isPublic\":true}},\"decoratorConfig\":{\"tableName\":\"employment_changes\",\"sensitive\":true,\"indexes\":[{\"name\":\"employment_changes_employment_idx\",\"columns\":[\"tenantId\",\"employmentId\",\"effectiveOn\"]}],\"api\":{\"include\":[]},\"cli\":{\"include\":[]},\"mcp\":{\"include\":[]},\"tenantScoped\":{\"mode\":\"required\"},\"conflictColumns\":[\"tenant_id\",\"slug\",\"context\"]},\"extends\":\"SmrtObject\",\"exportName\":\"EmploymentChange\",\"collectionExportName\":\"EmploymentChangeCollection\",\"validationRules\":[],\"schema\":{\"tableName\":\"employment_changes\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"employment_changes\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"employment_id\\\" UUID,\\n \\\"kind\\\" TEXT,\\n \\\"effective_on\\\" TEXT,\\n \\\"from_value\\\" TEXT,\\n \\\"to_value\\\" TEXT,\\n \\\"note\\\" TEXT,\\n \\\"actor_profile_id\\\" UUID\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"employment_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"employments\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"kind\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"effective_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"from_value\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"to_value\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"note\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"actor_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"employment_changes_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"employment_changes_employment_idx\",\"columns\":[\"tenant_id\",\"employment_id\",\"effective_on\"]},{\"name\":\"employment_changes_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"employment_changes_employment_id_idx\",\"columns\":[\"employment_id\"]},{\"name\":\"employment_changes_actor_profile_id_idx\",\"columns\":[\"actor_profile_id\"]}],\"version\":\"5d93422f\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:EmploymentCollection\":{\"name\":\"employmentcollection\",\"className\":\"EmploymentCollection\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:EmploymentCollection\",\"collection\":\"employments\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/employment/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{},\"methods\":{},\"decoratorConfig\":{\"api\":{\"include\":[]},\"mcp\":{\"include\":[]},\"cli\":false,\"tableName\":\"employments\"},\"extends\":\"SmrtCollection\",\"extendsTypeArg\":\"Employment\",\"exportName\":\"EmploymentCollection\",\"collectionExportName\":\"EmploymentCollectionCollection\",\"schema\":{\"tableName\":\"employments\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"employments\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"profile_id\\\" UUID,\\n \\\"user_id\\\" UUID,\\n \\\"employer_profile_id\\\" UUID,\\n \\\"employee_number\\\" TEXT,\\n \\\"worker_type\\\" TEXT,\\n \\\"position\\\" TEXT,\\n \\\"status\\\" TEXT\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"user_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"employer_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"employee_number\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"worker_type\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"position\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"status\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"employments_tenant_id_employee_number_idx\",\"columns\":[\"tenant_id\",\"employee_number\"],\"unique\":true,\"nullsNotDistinct\":true},{\"name\":\"employments_slug_context_idx\",\"columns\":[\"slug\",\"context\"]},{\"name\":\"employments_tenant_profile_uq\",\"columns\":[\"tenant_id\",\"profile_id\"],\"unique\":true},{\"name\":\"employments_tenant_user_idx\",\"columns\":[\"tenant_id\",\"user_id\"]},{\"name\":\"employments_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"employments_profile_id_idx\",\"columns\":[\"profile_id\"]},{\"name\":\"employments_user_id_idx\",\"columns\":[\"user_id\"]},{\"name\":\"employments_employer_profile_id_idx\",\"columns\":[\"employer_profile_id\"]}],\"version\":\"ff7ef7a8\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:EmploymentTermCollection\":{\"name\":\"employmenttermcollection\",\"className\":\"EmploymentTermCollection\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:EmploymentTermCollection\",\"collection\":\"employmentterms\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/employment/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{},\"methods\":{},\"decoratorConfig\":{\"api\":{\"include\":[]},\"mcp\":{\"include\":[]},\"cli\":false,\"tableName\":\"employment_terms\"},\"extends\":\"SmrtCollection\",\"extendsTypeArg\":\"EmploymentTerm\",\"exportName\":\"EmploymentTermCollection\",\"collectionExportName\":\"EmploymentTermCollectionCollection\",\"schema\":{\"tableName\":\"employment_terms\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"employment_terms\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"employment_id\\\" UUID,\\n \\\"started_on\\\" TEXT,\\n \\\"ended_on\\\" TEXT,\\n \\\"end_reason\\\" TEXT\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"employment_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"employments\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"started_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"ended_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"end_reason\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"employment_terms_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"employment_terms_employment_idx\",\"columns\":[\"tenant_id\",\"employment_id\",\"started_on\"]},{\"name\":\"employment_terms_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"employment_terms_employment_id_idx\",\"columns\":[\"employment_id\"]}],\"version\":\"165d209b\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:EmploymentChangeCollection\":{\"name\":\"employmentchangecollection\",\"className\":\"EmploymentChangeCollection\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:EmploymentChangeCollection\",\"collection\":\"employmentchanges\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/employment/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{},\"methods\":{},\"decoratorConfig\":{\"api\":{\"include\":[]},\"mcp\":{\"include\":[]},\"cli\":false,\"tableName\":\"employment_changes\"},\"extends\":\"SmrtCollection\",\"extendsTypeArg\":\"EmploymentChange\",\"exportName\":\"EmploymentChangeCollection\",\"collectionExportName\":\"EmploymentChangeCollectionCollection\",\"schema\":{\"tableName\":\"employment_changes\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"employment_changes\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"employment_id\\\" UUID,\\n \\\"kind\\\" TEXT,\\n \\\"effective_on\\\" TEXT,\\n \\\"from_value\\\" TEXT,\\n \\\"to_value\\\" TEXT,\\n \\\"note\\\" TEXT,\\n \\\"actor_profile_id\\\" UUID\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"employment_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"employments\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"kind\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"effective_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"from_value\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"to_value\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"note\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"actor_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"employment_changes_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"employment_changes_employment_idx\",\"columns\":[\"tenant_id\",\"employment_id\",\"effective_on\"]},{\"name\":\"employment_changes_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"employment_changes_employment_id_idx\",\"columns\":[\"employment_id\"]},{\"name\":\"employment_changes_actor_profile_id_idx\",\"columns\":[\"actor_profile_id\"]}],\"version\":\"5d93422f\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:Qualification\":{\"name\":\"qualification\",\"className\":\"Qualification\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:Qualification\",\"collection\":\"qualifications\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/qualifications/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{\"tenantId\":{\"type\":\"text\",\"required\":true,\"_meta\":{\"sqlType\":\"UUID\",\"__tenancy\":{\"isTenantIdField\":true,\"autoFilter\":true,\"required\":true,\"autoPopulate\":true,\"nullable\":false,\"mode\":\"required\",\"field\":\"tenantId\",\"allowSuperAdminBypass\":false}}},\"key\":{\"type\":\"text\",\"required\":false},\"name\":{\"type\":\"text\",\"required\":false},\"kind\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"issuingBody\":{\"type\":\"text\",\"required\":false},\"expires\":{\"type\":\"boolean\",\"required\":true,\"default\":false},\"validityMonths\":{\"type\":\"integer\",\"required\":false,\"_meta\":{\"nullable\":true}},\"scope\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"isActive\":{\"type\":\"boolean\",\"required\":true,\"default\":true}},\"methods\":{\"save\":{\"name\":\"save\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<this>\",\"isStatic\":false,\"isPublic\":true},\"delete\":{\"name\":\"delete\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<void>\",\"isStatic\":false,\"isPublic\":true}},\"decoratorConfig\":{\"tableName\":\"qualifications\",\"sensitive\":true,\"conflictColumns\":[\"tenant_id\",\"key\"],\"api\":{\"include\":[]},\"cli\":{\"include\":[]},\"mcp\":{\"include\":[]},\"tenantScoped\":{\"mode\":\"required\"}},\"extends\":\"SmrtObject\",\"exportName\":\"Qualification\",\"collectionExportName\":\"QualificationCollection\",\"validationRules\":[{\"field\":\"expires\",\"rule\":\"required\",\"fieldType\":\"boolean\"},{\"field\":\"isActive\",\"rule\":\"required\",\"fieldType\":\"boolean\"}],\"schema\":{\"tableName\":\"qualifications\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"qualifications\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"key\\\" TEXT,\\n \\\"name\\\" TEXT,\\n \\\"kind\\\" TEXT,\\n \\\"issuing_body\\\" TEXT,\\n \\\"expires\\\" BOOLEAN NOT NULL DEFAULT FALSE,\\n \\\"validity_months\\\" INTEGER,\\n \\\"scope\\\" TEXT,\\n \\\"is_active\\\" BOOLEAN NOT NULL DEFAULT TRUE\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"key\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"name\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"kind\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"issuing_body\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"expires\":{\"type\":\"BOOLEAN\",\"notNull\":true,\"unique\":false,\"default\":false},\"validity_months\":{\"type\":\"INTEGER\",\"notNull\":false,\"unique\":false},\"scope\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"is_active\":{\"type\":\"BOOLEAN\",\"notNull\":true,\"unique\":false,\"default\":true}},\"indexes\":[{\"name\":\"qualifications_tenant_id_key_idx\",\"columns\":[\"tenant_id\",\"key\"],\"unique\":true,\"nullsNotDistinct\":true},{\"name\":\"qualifications_slug_context_idx\",\"columns\":[\"slug\",\"context\"]},{\"name\":\"qualifications_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]}],\"version\":\"c9fa3721\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:HeldQualification\":{\"name\":\"heldqualification\",\"className\":\"HeldQualification\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:HeldQualification\",\"collection\":\"heldqualifications\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/qualifications/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{\"tenantId\":{\"type\":\"text\",\"required\":true,\"_meta\":{\"sqlType\":\"UUID\",\"__tenancy\":{\"isTenantIdField\":true,\"autoFilter\":true,\"required\":true,\"autoPopulate\":true,\"nullable\":false,\"mode\":\"required\",\"field\":\"tenantId\",\"allowSuperAdminBypass\":false}}},\"qualificationId\":{\"type\":\"foreignKey\",\"required\":false,\"related\":\"Qualification\",\"_meta\":{\"onDelete\":\"RESTRICT\"}},\"profileId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-profiles:Profile\",\"_meta\":{\"onDelete\":\"RESTRICT\"}},\"employmentId\":{\"type\":\"foreignKey\",\"required\":false,\"related\":\"Employment\",\"_meta\":{\"nullable\":true,\"onDelete\":\"RESTRICT\"}},\"issuedOn\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"expiresOn\":{\"type\":\"text\",\"required\":false,\"_meta\":{\"nullable\":true}},\"certificateNumber\":{\"type\":\"text\",\"required\":false},\"status\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"verifiedByProfileId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-profiles:Profile\",\"_meta\":{\"nullable\":true,\"onDelete\":\"RESTRICT\"}},\"verifiedAt\":{\"type\":\"datetime\",\"required\":false},\"documentAssetId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-assets:Asset\",\"_meta\":{\"nullable\":true}},\"renewalOfId\":{\"type\":\"foreignKey\",\"required\":false,\"related\":\"HeldQualification\",\"_meta\":{\"nullable\":true,\"onDelete\":\"RESTRICT\"}}},\"methods\":{\"save\":{\"name\":\"save\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<this>\",\"isStatic\":false,\"isPublic\":true},\"delete\":{\"name\":\"delete\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<void>\",\"isStatic\":false,\"isPublic\":true}},\"decoratorConfig\":{\"tableName\":\"held_qualifications\",\"sensitive\":true,\"indexes\":[{\"name\":\"held_qualifications_profile_idx\",\"columns\":[\"tenantId\",\"profileId\",\"qualificationId\"]},{\"name\":\"held_qualifications_expiry_idx\",\"columns\":[\"tenantId\",\"status\",\"expiresOn\"]}],\"api\":{\"include\":[]},\"cli\":{\"include\":[]},\"mcp\":{\"include\":[]},\"tenantScoped\":{\"mode\":\"required\"},\"conflictColumns\":[\"tenant_id\",\"slug\",\"context\"]},\"extends\":\"SmrtObject\",\"exportName\":\"HeldQualification\",\"collectionExportName\":\"HeldQualificationCollection\",\"validationRules\":[],\"schema\":{\"tableName\":\"held_qualifications\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"held_qualifications\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"qualification_id\\\" UUID,\\n \\\"profile_id\\\" UUID,\\n \\\"employment_id\\\" UUID,\\n \\\"issued_on\\\" TEXT,\\n \\\"expires_on\\\" TEXT,\\n \\\"certificate_number\\\" TEXT,\\n \\\"status\\\" TEXT,\\n \\\"verified_by_profile_id\\\" UUID,\\n \\\"verified_at\\\" TIMESTAMP,\\n \\\"document_asset_id\\\" UUID,\\n \\\"renewal_of_id\\\" UUID\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"qualification_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"qualifications\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"employment_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"employments\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"issued_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"expires_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"certificate_number\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"status\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"verified_by_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"verified_at\":{\"type\":\"TIMESTAMP\",\"notNull\":false,\"unique\":false},\"document_asset_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"renewal_of_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"held_qualifications\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}}},\"indexes\":[{\"name\":\"held_qualifications_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"held_qualifications_profile_idx\",\"columns\":[\"tenant_id\",\"profile_id\",\"qualification_id\"]},{\"name\":\"held_qualifications_expiry_idx\",\"columns\":[\"tenant_id\",\"status\",\"expires_on\"]},{\"name\":\"held_qualifications_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"held_qualifications_qualification_id_idx\",\"columns\":[\"qualification_id\"]},{\"name\":\"held_qualifications_profile_id_idx\",\"columns\":[\"profile_id\"]},{\"name\":\"held_qualifications_employment_id_idx\",\"columns\":[\"employment_id\"]},{\"name\":\"held_qualifications_verified_by_profile_id_idx\",\"columns\":[\"verified_by_profile_id\"]},{\"name\":\"held_qualifications_document_asset_id_idx\",\"columns\":[\"document_asset_id\"]},{\"name\":\"held_qualifications_renewal_of_id_idx\",\"columns\":[\"renewal_of_id\"]}],\"version\":\"fafbe37f\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:HeldQualificationChange\":{\"name\":\"heldqualificationchange\",\"className\":\"HeldQualificationChange\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:HeldQualificationChange\",\"collection\":\"heldqualificationchanges\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/qualifications/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{\"tenantId\":{\"type\":\"text\",\"required\":true,\"_meta\":{\"sqlType\":\"UUID\",\"__tenancy\":{\"isTenantIdField\":true,\"autoFilter\":true,\"required\":true,\"autoPopulate\":true,\"nullable\":false,\"mode\":\"required\",\"field\":\"tenantId\",\"allowSuperAdminBypass\":false}}},\"heldQualificationId\":{\"type\":\"foreignKey\",\"required\":false,\"related\":\"HeldQualification\",\"_meta\":{\"onDelete\":\"RESTRICT\"}},\"kind\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"effectiveOn\":{\"type\":\"text\",\"required\":false,\"_meta\":{}},\"reason\":{\"type\":\"text\",\"required\":false},\"actorProfileId\":{\"type\":\"crossPackageRef\",\"required\":false,\"related\":\"@happyvertical/smrt-profiles:Profile\",\"_meta\":{\"onDelete\":\"RESTRICT\"}}},\"methods\":{\"save\":{\"name\":\"save\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<this>\",\"isStatic\":false,\"isPublic\":true},\"delete\":{\"name\":\"delete\",\"async\":true,\"parameters\":[],\"returnType\":\"Promise<void>\",\"isStatic\":false,\"isPublic\":true}},\"decoratorConfig\":{\"tableName\":\"held_qualification_changes\",\"sensitive\":true,\"indexes\":[{\"name\":\"held_qualification_changes_held_idx\",\"columns\":[\"tenantId\",\"heldQualificationId\",\"effectiveOn\"]}],\"api\":{\"include\":[]},\"cli\":{\"include\":[]},\"mcp\":{\"include\":[]},\"tenantScoped\":{\"mode\":\"required\"},\"conflictColumns\":[\"tenant_id\",\"slug\",\"context\"]},\"extends\":\"SmrtObject\",\"exportName\":\"HeldQualificationChange\",\"collectionExportName\":\"HeldQualificationChangeCollection\",\"validationRules\":[],\"schema\":{\"tableName\":\"held_qualification_changes\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"held_qualification_changes\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"held_qualification_id\\\" UUID,\\n \\\"kind\\\" TEXT,\\n \\\"effective_on\\\" TEXT,\\n \\\"reason\\\" TEXT,\\n \\\"actor_profile_id\\\" UUID\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"held_qualification_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"held_qualifications\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"kind\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"effective_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"reason\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"actor_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"held_qualification_changes_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"held_qualification_changes_held_idx\",\"columns\":[\"tenant_id\",\"held_qualification_id\",\"effective_on\"]},{\"name\":\"held_qualification_changes_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"held_qualification_changes_held_qualification_id_idx\",\"columns\":[\"held_qualification_id\"]},{\"name\":\"held_qualification_changes_actor_profile_id_idx\",\"columns\":[\"actor_profile_id\"]}],\"version\":\"ed9ed5e1\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:QualificationCollection\":{\"name\":\"qualificationcollection\",\"className\":\"QualificationCollection\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:QualificationCollection\",\"collection\":\"qualifications\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/qualifications/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{},\"methods\":{},\"decoratorConfig\":{\"api\":{\"include\":[]},\"mcp\":{\"include\":[]},\"cli\":false,\"tableName\":\"qualifications\"},\"extends\":\"SmrtCollection\",\"extendsTypeArg\":\"Qualification\",\"exportName\":\"QualificationCollection\",\"collectionExportName\":\"QualificationCollectionCollection\",\"schema\":{\"tableName\":\"qualifications\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"qualifications\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"key\\\" TEXT,\\n \\\"name\\\" TEXT,\\n \\\"kind\\\" TEXT,\\n \\\"issuing_body\\\" TEXT,\\n \\\"expires\\\" BOOLEAN NOT NULL DEFAULT FALSE,\\n \\\"validity_months\\\" INTEGER,\\n \\\"scope\\\" TEXT,\\n \\\"is_active\\\" BOOLEAN NOT NULL DEFAULT TRUE\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"key\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"name\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"kind\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"issuing_body\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"expires\":{\"type\":\"BOOLEAN\",\"notNull\":true,\"unique\":false,\"default\":false},\"validity_months\":{\"type\":\"INTEGER\",\"notNull\":false,\"unique\":false},\"scope\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"is_active\":{\"type\":\"BOOLEAN\",\"notNull\":true,\"unique\":false,\"default\":true}},\"indexes\":[{\"name\":\"qualifications_tenant_id_key_idx\",\"columns\":[\"tenant_id\",\"key\"],\"unique\":true,\"nullsNotDistinct\":true},{\"name\":\"qualifications_slug_context_idx\",\"columns\":[\"slug\",\"context\"]},{\"name\":\"qualifications_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]}],\"version\":\"c9fa3721\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:HeldQualificationCollection\":{\"name\":\"heldqualificationcollection\",\"className\":\"HeldQualificationCollection\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:HeldQualificationCollection\",\"collection\":\"heldqualifications\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/qualifications/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{},\"methods\":{},\"decoratorConfig\":{\"api\":{\"include\":[]},\"mcp\":{\"include\":[]},\"cli\":false,\"tableName\":\"held_qualifications\"},\"extends\":\"SmrtCollection\",\"extendsTypeArg\":\"HeldQualification\",\"exportName\":\"HeldQualificationCollection\",\"collectionExportName\":\"HeldQualificationCollectionCollection\",\"schema\":{\"tableName\":\"held_qualifications\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"held_qualifications\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"qualification_id\\\" UUID,\\n \\\"profile_id\\\" UUID,\\n \\\"employment_id\\\" UUID,\\n \\\"issued_on\\\" TEXT,\\n \\\"expires_on\\\" TEXT,\\n \\\"certificate_number\\\" TEXT,\\n \\\"status\\\" TEXT,\\n \\\"verified_by_profile_id\\\" UUID,\\n \\\"verified_at\\\" TIMESTAMP,\\n \\\"document_asset_id\\\" UUID,\\n \\\"renewal_of_id\\\" UUID\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"qualification_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"qualifications\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"employment_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"employments\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"issued_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"expires_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"certificate_number\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"status\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"verified_by_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"verified_at\":{\"type\":\"TIMESTAMP\",\"notNull\":false,\"unique\":false},\"document_asset_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false},\"renewal_of_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"held_qualifications\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}}},\"indexes\":[{\"name\":\"held_qualifications_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"held_qualifications_profile_idx\",\"columns\":[\"tenant_id\",\"profile_id\",\"qualification_id\"]},{\"name\":\"held_qualifications_expiry_idx\",\"columns\":[\"tenant_id\",\"status\",\"expires_on\"]},{\"name\":\"held_qualifications_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"held_qualifications_qualification_id_idx\",\"columns\":[\"qualification_id\"]},{\"name\":\"held_qualifications_profile_id_idx\",\"columns\":[\"profile_id\"]},{\"name\":\"held_qualifications_employment_id_idx\",\"columns\":[\"employment_id\"]},{\"name\":\"held_qualifications_verified_by_profile_id_idx\",\"columns\":[\"verified_by_profile_id\"]},{\"name\":\"held_qualifications_document_asset_id_idx\",\"columns\":[\"document_asset_id\"]},{\"name\":\"held_qualifications_renewal_of_id_idx\",\"columns\":[\"renewal_of_id\"]}],\"version\":\"fafbe37f\"},\"importPath\":\"@happyvertical/smrt-human-resources\"},\"@happyvertical/smrt-human-resources:HeldQualificationChangeCollection\":{\"name\":\"heldqualificationchangecollection\",\"className\":\"HeldQualificationChangeCollection\",\"qualifiedName\":\"@happyvertical/smrt-human-resources:HeldQualificationChangeCollection\",\"collection\":\"heldqualificationchanges\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/human-resources/src/qualifications/models.ts\",\"packageName\":\"@happyvertical/smrt-human-resources\",\"fields\":{},\"methods\":{},\"decoratorConfig\":{\"api\":{\"include\":[]},\"mcp\":{\"include\":[]},\"cli\":false,\"tableName\":\"held_qualification_changes\"},\"extends\":\"SmrtCollection\",\"extendsTypeArg\":\"HeldQualificationChange\",\"exportName\":\"HeldQualificationChangeCollection\",\"collectionExportName\":\"HeldQualificationChangeCollectionCollection\",\"schema\":{\"tableName\":\"held_qualification_changes\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"held_qualification_changes\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"tenant_id\\\" UUID NOT NULL,\\n \\\"held_qualification_id\\\" UUID,\\n \\\"kind\\\" TEXT,\\n \\\"effective_on\\\" TEXT,\\n \\\"reason\\\" TEXT,\\n \\\"actor_profile_id\\\" UUID\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"tenantId\",\"notNull\":true,\"unique\":false},\"held_qualification_id\":{\"type\":\"UUID\",\"referenceKind\":\"foreignKey\",\"notNull\":false,\"unique\":false,\"foreignKey\":{\"table\":\"held_qualifications\",\"column\":\"id\",\"onDelete\":\"RESTRICT\",\"onUpdate\":\"CASCADE\"}},\"kind\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"effective_on\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"reason\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false},\"actor_profile_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":false,\"unique\":false}},\"indexes\":[{\"name\":\"held_qualification_changes_slug_context_idx\",\"columns\":[\"tenant_id\",\"slug\",\"context\"],\"unique\":true},{\"name\":\"held_qualification_changes_held_idx\",\"columns\":[\"tenant_id\",\"held_qualification_id\",\"effective_on\"]},{\"name\":\"held_qualification_changes_tenant_id_created_at_idx\",\"columns\":[\"tenant_id\",\"created_at\"]},{\"name\":\"held_qualification_changes_held_qualification_id_idx\",\"columns\":[\"held_qualification_id\"]},{\"name\":\"held_qualification_changes_actor_profile_id_idx\",\"columns\":[\"actor_profile_id\"]}],\"version\":\"ed9ed5e1\"},\"importPath\":\"@happyvertical/smrt-human-resources\"}},\"moduleType\":\"smrt\",\"smrtDependencies\":[\"@happyvertical/smrt-core\",\"@happyvertical/smrt-tenancy\"]}"));
9
+ //#endregion
10
+ //#region src/dates.ts
11
+ var ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
12
+ function isIsoDate(value) {
13
+ if (typeof value !== "string") return false;
14
+ const match = ISO_DATE.exec(value);
15
+ if (!match) return false;
16
+ const [year, month, day] = match.slice(1).map(Number);
17
+ const date = new Date(Date.UTC(year, month - 1, day));
18
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
19
+ }
20
+ function assertIsoDate(fieldName, value) {
21
+ if (!isIsoDate(value)) throw new HrError("HR_INVALID", `${fieldName} must be a calendar date written YYYY-MM-DD, got '${String(value)}'.`);
22
+ return value;
23
+ }
24
+ function addDays(date, days) {
25
+ const [year, month, day] = assertIsoDate("date", date).split("-").map(Number);
26
+ return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
27
+ }
28
+ function addMonths(date, months) {
29
+ const [year, month, day] = assertIsoDate("date", date).split("-").map(Number);
30
+ const first = new Date(Date.UTC(year, month - 1 + months, 1));
31
+ const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
32
+ first.setUTCDate(Math.min(day, lastDay));
33
+ return first.toISOString().slice(0, 10);
34
+ }
35
+ function dateWithin(on, start, end) {
36
+ return start <= on && (end === null || on <= end);
37
+ }
38
+ //#endregion
39
+ //#region src/write.ts
40
+ var permitted = /* @__PURE__ */ new WeakSet();
41
+ function hasHrWrite(object) {
42
+ return permitted.has(object);
43
+ }
44
+ function assertHrWrite(object) {
45
+ if (!hasHrWrite(object)) throw new HrError("HR_WRITE_FORBIDDEN", "HR records are writable only through EmploymentService and QualificationService.");
46
+ }
47
+ async function persistHr(object) {
48
+ permitted.add(object);
49
+ try {
50
+ await object.save();
51
+ return object;
52
+ } finally {
53
+ permitted.delete(object);
54
+ }
55
+ }
56
+ async function insertHr(Model, db, values) {
57
+ const row = await new Model({
58
+ db,
59
+ _skipLoad: true,
60
+ ...values
61
+ }).initialize();
62
+ row.requireInsertOnSave();
63
+ return persistHr(row);
64
+ }
65
+ //#endregion
66
+ //#region src/employment/models.ts
67
+ var __defProp$1 = Object.defineProperty;
68
+ var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
69
+ var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, {
70
+ enumerable: true,
71
+ configurable: true,
72
+ writable: true,
73
+ value
74
+ }) : obj[key] = value;
75
+ var __decorateClass$1 = (decorators, target, key, kind) => {
76
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
77
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
78
+ if (kind && result) __defProp$1(target, key, result);
79
+ return result;
80
+ };
81
+ var __publicField$1 = (obj, key, value) => __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
82
+ var PACKAGE_OWNED_COLUMNS = [
83
+ ["tenant_id", "tenantId"],
84
+ ["profile_id", "profileId"],
85
+ ["user_id", "userId"],
86
+ ["employer_profile_id", "employerProfileId"],
87
+ ["employee_number", "employeeNumber"],
88
+ ["worker_type", "workerType"],
89
+ ["position", "position"],
90
+ ["status", "status"]
91
+ ];
92
+ var Employment = class extends SmrtObject {
93
+ tenantId = "";
94
+ profileId = "";
95
+ userId = null;
96
+ employerProfileId = null;
97
+ /** Free text, unique per tenant. */
98
+ employeeNumber = "";
99
+ workerType = "employee";
100
+ position = null;
101
+ status = "active";
102
+ async save() {
103
+ const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
104
+ if (!hasHrWrite(this) && (!prior || PACKAGE_OWNED_COLUMNS.some(([column, property]) => (prior[column] ?? null) !== (this[property] ?? null)))) throw new HrError("HR_WRITE_FORBIDDEN", "Employment columns owned by the HR package are writable only through EmploymentService.");
105
+ if (prior && (prior.tenant_id !== this.tenantId || prior.profile_id !== this.profileId)) throw new HrError("HR_HISTORY_IMMUTABLE", "Employment identity is immutable.");
106
+ return await super.save();
107
+ }
108
+ async delete() {
109
+ throw new HrError("HR_HISTORY_IMMUTABLE", "Employment history cannot be deleted; end the employment instead.");
110
+ }
111
+ };
112
+ __decorateClass$1([tenantId()], Employment.prototype, "tenantId", 2);
113
+ __decorateClass$1([crossPackageRef("@happyvertical/smrt-profiles:Profile", { onDelete: "RESTRICT" })], Employment.prototype, "profileId", 2);
114
+ __decorateClass$1([crossPackageRef("@happyvertical/smrt-users:User", { nullable: true })], Employment.prototype, "userId", 2);
115
+ __decorateClass$1([crossPackageRef("@happyvertical/smrt-profiles:Profile", {
116
+ nullable: true,
117
+ onDelete: "RESTRICT"
118
+ })], Employment.prototype, "employerProfileId", 2);
119
+ __decorateClass$1([field({ type: "text" })], Employment.prototype, "workerType", 2);
120
+ __decorateClass$1([field({
121
+ type: "text",
122
+ nullable: true
123
+ })], Employment.prototype, "position", 2);
124
+ __decorateClass$1([field({ type: "text" })], Employment.prototype, "status", 2);
125
+ Employment = __decorateClass$1([TenantScoped({ mode: "required" }), smrt({
126
+ tableName: "employments",
127
+ sensitive: true,
128
+ conflictColumns: ["tenant_id", "employee_number"],
129
+ indexes: [{
130
+ name: "employments_tenant_profile_uq",
131
+ columns: ["tenantId", "profileId"],
132
+ unique: true
133
+ }, {
134
+ name: "employments_tenant_user_idx",
135
+ columns: ["tenantId", "userId"]
136
+ }],
137
+ api: { include: [] },
138
+ cli: { include: [] },
139
+ mcp: { include: [] }
140
+ })], Employment);
141
+ var EmploymentTerm = class extends SmrtObject {
142
+ tenantId = "";
143
+ employmentId = "";
144
+ startedOn = "";
145
+ endedOn = null;
146
+ endReason = "";
147
+ async save() {
148
+ assertHrWrite(this);
149
+ const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
150
+ if (prior && (prior.ended_on != null || prior.employment_id !== this.employmentId || prior.started_on !== this.startedOn)) throw new HrError("HR_HISTORY_IMMUTABLE", "A term can only be closed once; its start and employment never change.");
151
+ return await super.save();
152
+ }
153
+ async delete() {
154
+ throw new HrError("HR_HISTORY_IMMUTABLE", "Employment terms cannot be deleted.");
155
+ }
156
+ };
157
+ __decorateClass$1([tenantId()], EmploymentTerm.prototype, "tenantId", 2);
158
+ __decorateClass$1([foreignKey("Employment", { onDelete: "RESTRICT" })], EmploymentTerm.prototype, "employmentId", 2);
159
+ __decorateClass$1([field({ type: "text" })], EmploymentTerm.prototype, "startedOn", 2);
160
+ __decorateClass$1([field({
161
+ type: "text",
162
+ nullable: true
163
+ })], EmploymentTerm.prototype, "endedOn", 2);
164
+ EmploymentTerm = __decorateClass$1([TenantScoped({ mode: "required" }), smrt({
165
+ tableName: "employment_terms",
166
+ sensitive: true,
167
+ indexes: [{
168
+ name: "employment_terms_employment_idx",
169
+ columns: [
170
+ "tenantId",
171
+ "employmentId",
172
+ "startedOn"
173
+ ]
174
+ }],
175
+ api: { include: [] },
176
+ cli: { include: [] },
177
+ mcp: { include: [] }
178
+ })], EmploymentTerm);
179
+ var EmploymentChange = class extends SmrtObject {
180
+ tenantId = "";
181
+ employmentId = "";
182
+ kind = "hired";
183
+ effectiveOn = "";
184
+ fromValue = null;
185
+ toValue = null;
186
+ note = "";
187
+ actorProfileId = "";
188
+ async save() {
189
+ assertHrWrite(this);
190
+ if (this.id && await this.db.get(this.tableName, { id: this.id })) throw new HrError("HR_HISTORY_IMMUTABLE", "Employment changes are append-only.");
191
+ this.requireInsertOnSave();
192
+ return await super.save();
193
+ }
194
+ async delete() {
195
+ throw new HrError("HR_HISTORY_IMMUTABLE", "Employment changes cannot be deleted.");
196
+ }
197
+ };
198
+ __decorateClass$1([tenantId()], EmploymentChange.prototype, "tenantId", 2);
199
+ __decorateClass$1([foreignKey("Employment", { onDelete: "RESTRICT" })], EmploymentChange.prototype, "employmentId", 2);
200
+ __decorateClass$1([field({ type: "text" })], EmploymentChange.prototype, "kind", 2);
201
+ __decorateClass$1([field({ type: "text" })], EmploymentChange.prototype, "effectiveOn", 2);
202
+ __decorateClass$1([field({
203
+ type: "text",
204
+ nullable: true
205
+ })], EmploymentChange.prototype, "fromValue", 2);
206
+ __decorateClass$1([field({
207
+ type: "text",
208
+ nullable: true
209
+ })], EmploymentChange.prototype, "toValue", 2);
210
+ __decorateClass$1([crossPackageRef("@happyvertical/smrt-profiles:Profile", { onDelete: "RESTRICT" })], EmploymentChange.prototype, "actorProfileId", 2);
211
+ EmploymentChange = __decorateClass$1([TenantScoped({ mode: "required" }), smrt({
212
+ tableName: "employment_changes",
213
+ sensitive: true,
214
+ indexes: [{
215
+ name: "employment_changes_employment_idx",
216
+ columns: [
217
+ "tenantId",
218
+ "employmentId",
219
+ "effectiveOn"
220
+ ]
221
+ }],
222
+ api: { include: [] },
223
+ cli: { include: [] },
224
+ mcp: { include: [] }
225
+ })], EmploymentChange);
226
+ var EmploymentCollection = class extends SmrtCollection {};
227
+ __publicField$1(EmploymentCollection, "_itemClass", Employment);
228
+ EmploymentCollection = __decorateClass$1([smrt({
229
+ api: { include: [] },
230
+ mcp: { include: [] },
231
+ cli: false
232
+ })], EmploymentCollection);
233
+ var EmploymentTermCollection = class extends SmrtCollection {};
234
+ __publicField$1(EmploymentTermCollection, "_itemClass", EmploymentTerm);
235
+ EmploymentTermCollection = __decorateClass$1([smrt({
236
+ api: { include: [] },
237
+ mcp: { include: [] },
238
+ cli: false
239
+ })], EmploymentTermCollection);
240
+ var EmploymentChangeCollection = class extends SmrtCollection {};
241
+ __publicField$1(EmploymentChangeCollection, "_itemClass", EmploymentChange);
242
+ EmploymentChangeCollection = __decorateClass$1([smrt({
243
+ api: { include: [] },
244
+ mcp: { include: [] },
245
+ cli: false
246
+ })], EmploymentChangeCollection);
247
+ //#endregion
248
+ //#region src/qualifications/models.ts
249
+ var __defProp = Object.defineProperty;
250
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
251
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
252
+ enumerable: true,
253
+ configurable: true,
254
+ writable: true,
255
+ value
256
+ }) : obj[key] = value;
257
+ var __decorateClass = (decorators, target, key, kind) => {
258
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
259
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
260
+ if (kind && result) __defProp(target, key, result);
261
+ return result;
262
+ };
263
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
264
+ var Qualification = class extends SmrtObject {
265
+ tenantId = "";
266
+ /** Lowercase kebab-case, unique per tenant: `first-aid`. */
267
+ key = "";
268
+ name = "";
269
+ kind = "certification";
270
+ issuingBody = "";
271
+ /** Whether held qualifications of this kind expire. */
272
+ expires = false;
273
+ validityMonths = null;
274
+ scope = "person";
275
+ isActive = true;
276
+ async save() {
277
+ assertHrWrite(this);
278
+ const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
279
+ if (prior && (prior.key !== this.key || prior.scope !== this.scope)) throw new HrError("HR_HISTORY_IMMUTABLE", "A qualification key and scope are immutable; deactivate it instead.");
280
+ return await super.save();
281
+ }
282
+ async delete() {
283
+ throw new HrError("HR_HISTORY_IMMUTABLE", "Qualifications cannot be deleted; deactivate them instead.");
284
+ }
285
+ };
286
+ __decorateClass([tenantId()], Qualification.prototype, "tenantId", 2);
287
+ __decorateClass([field({ type: "text" })], Qualification.prototype, "kind", 2);
288
+ __decorateClass([field({
289
+ type: "integer",
290
+ nullable: true
291
+ })], Qualification.prototype, "validityMonths", 2);
292
+ __decorateClass([field({ type: "text" })], Qualification.prototype, "scope", 2);
293
+ Qualification = __decorateClass([TenantScoped({ mode: "required" }), smrt({
294
+ tableName: "qualifications",
295
+ sensitive: true,
296
+ conflictColumns: ["tenant_id", "key"],
297
+ api: { include: [] },
298
+ cli: { include: [] },
299
+ mcp: { include: [] }
300
+ })], Qualification);
301
+ var FROZEN_WHEN_REVOKED = [
302
+ ["tenant_id", "tenantId"],
303
+ ["qualification_id", "qualificationId"],
304
+ ["profile_id", "profileId"],
305
+ ["employment_id", "employmentId"],
306
+ ["issued_on", "issuedOn"],
307
+ ["expires_on", "expiresOn"],
308
+ ["certificate_number", "certificateNumber"],
309
+ ["status", "status"],
310
+ ["renewal_of_id", "renewalOfId"]
311
+ ];
312
+ var HeldQualification = class extends SmrtObject {
313
+ tenantId = "";
314
+ qualificationId = "";
315
+ profileId = "";
316
+ employmentId = null;
317
+ issuedOn = "";
318
+ expiresOn = null;
319
+ certificateNumber = "";
320
+ status = "valid";
321
+ verifiedByProfileId = null;
322
+ verifiedAt = null;
323
+ documentAssetId = null;
324
+ renewalOfId = null;
325
+ async save() {
326
+ assertHrWrite(this);
327
+ const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
328
+ if (prior && (prior.tenant_id !== this.tenantId || prior.profile_id !== this.profileId || prior.qualification_id !== this.qualificationId || prior.issued_on !== this.issuedOn)) throw new HrError("HR_HISTORY_IMMUTABLE", "A held qualification keeps its person, qualification and issue date; renew it instead.");
329
+ if (prior?.status === "revoked" && FROZEN_WHEN_REVOKED.some(([column, property]) => (prior[column] ?? null) !== (this[property] ?? null))) throw new HrError("HR_HISTORY_IMMUTABLE", "A revoked qualification never changes, apart from its verification; grant a new one.");
330
+ return await super.save();
331
+ }
332
+ async delete() {
333
+ throw new HrError("HR_HISTORY_IMMUTABLE", "Held qualifications cannot be deleted; revoke them instead.");
334
+ }
335
+ };
336
+ __decorateClass([tenantId()], HeldQualification.prototype, "tenantId", 2);
337
+ __decorateClass([foreignKey(Qualification, { onDelete: "RESTRICT" })], HeldQualification.prototype, "qualificationId", 2);
338
+ __decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile", { onDelete: "RESTRICT" })], HeldQualification.prototype, "profileId", 2);
339
+ __decorateClass([foreignKey("Employment", {
340
+ nullable: true,
341
+ onDelete: "RESTRICT"
342
+ })], HeldQualification.prototype, "employmentId", 2);
343
+ __decorateClass([field({ type: "text" })], HeldQualification.prototype, "issuedOn", 2);
344
+ __decorateClass([field({
345
+ type: "text",
346
+ nullable: true
347
+ })], HeldQualification.prototype, "expiresOn", 2);
348
+ __decorateClass([field({ type: "text" })], HeldQualification.prototype, "status", 2);
349
+ __decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile", {
350
+ nullable: true,
351
+ onDelete: "RESTRICT"
352
+ })], HeldQualification.prototype, "verifiedByProfileId", 2);
353
+ __decorateClass([crossPackageRef("@happyvertical/smrt-assets:Asset", { nullable: true })], HeldQualification.prototype, "documentAssetId", 2);
354
+ __decorateClass([foreignKey("HeldQualification", {
355
+ nullable: true,
356
+ onDelete: "RESTRICT"
357
+ })], HeldQualification.prototype, "renewalOfId", 2);
358
+ HeldQualification = __decorateClass([TenantScoped({ mode: "required" }), smrt({
359
+ tableName: "held_qualifications",
360
+ sensitive: true,
361
+ indexes: [{
362
+ name: "held_qualifications_profile_idx",
363
+ columns: [
364
+ "tenantId",
365
+ "profileId",
366
+ "qualificationId"
367
+ ]
368
+ }, {
369
+ name: "held_qualifications_expiry_idx",
370
+ columns: [
371
+ "tenantId",
372
+ "status",
373
+ "expiresOn"
374
+ ]
375
+ }],
376
+ api: { include: [] },
377
+ cli: { include: [] },
378
+ mcp: { include: [] }
379
+ })], HeldQualification);
380
+ var HeldQualificationChange = class extends SmrtObject {
381
+ tenantId = "";
382
+ heldQualificationId = "";
383
+ kind = "granted";
384
+ effectiveOn = "";
385
+ reason = "";
386
+ actorProfileId = "";
387
+ async save() {
388
+ assertHrWrite(this);
389
+ if (this.id && await this.db.get(this.tableName, { id: this.id })) throw new HrError("HR_HISTORY_IMMUTABLE", "Held qualification changes are append-only.");
390
+ this.requireInsertOnSave();
391
+ return await super.save();
392
+ }
393
+ async delete() {
394
+ throw new HrError("HR_HISTORY_IMMUTABLE", "Held qualification changes cannot be deleted.");
395
+ }
396
+ };
397
+ __decorateClass([tenantId()], HeldQualificationChange.prototype, "tenantId", 2);
398
+ __decorateClass([foreignKey(HeldQualification, { onDelete: "RESTRICT" })], HeldQualificationChange.prototype, "heldQualificationId", 2);
399
+ __decorateClass([field({ type: "text" })], HeldQualificationChange.prototype, "kind", 2);
400
+ __decorateClass([field({ type: "text" })], HeldQualificationChange.prototype, "effectiveOn", 2);
401
+ __decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile", { onDelete: "RESTRICT" })], HeldQualificationChange.prototype, "actorProfileId", 2);
402
+ HeldQualificationChange = __decorateClass([TenantScoped({ mode: "required" }), smrt({
403
+ tableName: "held_qualification_changes",
404
+ sensitive: true,
405
+ indexes: [{
406
+ name: "held_qualification_changes_held_idx",
407
+ columns: [
408
+ "tenantId",
409
+ "heldQualificationId",
410
+ "effectiveOn"
411
+ ]
412
+ }],
413
+ api: { include: [] },
414
+ cli: { include: [] },
415
+ mcp: { include: [] }
416
+ })], HeldQualificationChange);
417
+ var QualificationCollection = class extends SmrtCollection {};
418
+ __publicField(QualificationCollection, "_itemClass", Qualification);
419
+ QualificationCollection = __decorateClass([smrt({
420
+ api: { include: [] },
421
+ mcp: { include: [] },
422
+ cli: false
423
+ })], QualificationCollection);
424
+ var HeldQualificationCollection = class extends SmrtCollection {};
425
+ __publicField(HeldQualificationCollection, "_itemClass", HeldQualification);
426
+ HeldQualificationCollection = __decorateClass([smrt({
427
+ api: { include: [] },
428
+ mcp: { include: [] },
429
+ cli: false
430
+ })], HeldQualificationCollection);
431
+ var HeldQualificationChangeCollection = class extends SmrtCollection {};
432
+ __publicField(HeldQualificationChangeCollection, "_itemClass", HeldQualificationChange);
433
+ HeldQualificationChangeCollection = __decorateClass([smrt({
434
+ api: { include: [] },
435
+ mcp: { include: [] },
436
+ cli: false
437
+ })], HeldQualificationChangeCollection);
438
+ //#endregion
439
+ //#region src/qualifications/internal.ts
440
+ var EMPLOYMENT_ENDED_REASON = "employment-ended";
441
+ async function revokeEmploymentQualifications(db, actor, employmentId, effectiveOn, queue) {
442
+ const rows = await (await HeldQualificationCollection.create({ db })).list({ where: {
443
+ tenantId: actor.tenantId,
444
+ employmentId
445
+ } });
446
+ if (rows.length === 0) return [];
447
+ const changes = await HeldQualificationChangeCollection.create({ db });
448
+ const revokedOn = /* @__PURE__ */ new Map();
449
+ for (const change of await changes.list({ where: {
450
+ tenantId: actor.tenantId,
451
+ heldQualificationId: rows.map((row) => row.id),
452
+ kind: "revoked"
453
+ } })) {
454
+ const earliest = revokedOn.get(change.heldQualificationId);
455
+ if (earliest === void 0 || change.effectiveOn < earliest) revokedOn.set(change.heldQualificationId, change.effectiveOn);
456
+ }
457
+ const byId = new Map(rows.map((row) => [row.id, row]));
458
+ const renewed = new Set(rows.map((row) => row.renewalOfId));
459
+ const cut = [];
460
+ for (const row of rows) {
461
+ if (renewed.has(row.id)) continue;
462
+ let already;
463
+ const seen = /* @__PURE__ */ new Set();
464
+ for (let member = row; member && !seen.has(member.id); member = member.renewalOfId ? byId.get(member.renewalOfId) : void 0) {
465
+ seen.add(member.id);
466
+ const on = revokedOn.get(member.id);
467
+ if (on !== void 0 && (already === void 0 || on < already)) already = on;
468
+ }
469
+ if (already !== void 0 && already <= effectiveOn) continue;
470
+ if (row.status !== "revoked") {
471
+ row.status = "revoked";
472
+ await persistHr(row);
473
+ }
474
+ await insertHr(HeldQualificationChange, db, {
475
+ tenantId: actor.tenantId,
476
+ heldQualificationId: row.id,
477
+ kind: "revoked",
478
+ effectiveOn,
479
+ reason: EMPLOYMENT_ENDED_REASON,
480
+ actorProfileId: actor.profileId
481
+ });
482
+ queue({
483
+ type: "held-qualification.revoked",
484
+ heldQualification: row,
485
+ effectiveOn,
486
+ at: /* @__PURE__ */ new Date(),
487
+ byProfileId: actor.profileId
488
+ });
489
+ cut.push(row);
490
+ }
491
+ return cut;
492
+ }
493
+ //#endregion
494
+ //#region src/service-base.ts
495
+ var logger = createLogger({ level: "info" });
496
+ var HrService = class {
497
+ constructor(db, actor, options = {}) {
498
+ this.db = db;
499
+ if (!actor?.tenantId?.trim() || !actor?.profileId?.trim()) throw new HrError("HR_ACTOR_INVALID", "HR services require a trusted tenant/profile actor.");
500
+ this.actor = Object.freeze({
501
+ tenantId: this.identity(actor.tenantId),
502
+ profileId: this.identity(actor.profileId)
503
+ });
504
+ this.onEvent = options.onEvent;
505
+ }
506
+ db;
507
+ actor;
508
+ onEvent;
509
+ /** Normalize an id; PostgreSQL stores identities as UUIDs. */
510
+ identity(value) {
511
+ if (!isPostgresDatabase(this.db)) return value;
512
+ const hex = value.replace(/[{}-]/g, "").toLowerCase();
513
+ if (!/^[0-9a-f]{32}$/.test(hex)) throw new HrError("HR_INVALID", "PostgreSQL HR identities must be UUIDs.");
514
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
515
+ }
516
+ /**
517
+ * A required id from caller input, trimmed and normalized.
518
+ *
519
+ * @throws HrError `HR_INVALID` when it is not text or is blank
520
+ */
521
+ id(fieldName, value) {
522
+ if (typeof value !== "string" || !value.trim()) throw new HrError("HR_INVALID", `${fieldName} is required.`);
523
+ return this.identity(value.trim());
524
+ }
525
+ /** An optional id from caller input: null or undefined is null. */
526
+ optionalId(fieldName, value) {
527
+ return value === null || value === void 0 ? null : this.id(fieldName, value);
528
+ }
529
+ /** Run a read inside the actor's tenant. */
530
+ scope(run) {
531
+ const active = getTenantId();
532
+ if (active && this.identity(active) !== this.actor.tenantId) throw new HrError("HR_TENANT_MISMATCH", "HR actor differs from active tenant.");
533
+ return withTenant({ tenantId: this.actor.tenantId }, run);
534
+ }
535
+ /**
536
+ * Run a mutation in one transaction, serialized per tenant, then deliver the
537
+ * events it queued. Nothing is delivered when the transaction fails.
538
+ *
539
+ * @throws HrError `HR_TRANSACTION_UNSUPPORTED` when the service was constructed with a handle that is already inside a transaction
540
+ */
541
+ async transact(run) {
542
+ const unsupported = () => new HrError("HR_TRANSACTION_UNSUPPORTED", "HR services must be constructed with the root database handle, not one that is already inside a transaction: events are delivered when the service commits its own transaction.");
543
+ const begin = this.db.beginTransaction;
544
+ if (typeof begin !== "function") throw unsupported();
545
+ const events = [];
546
+ const result = await this.scope(() => withEmbeddedWriteQueue(this.db, isEmbeddedDatabase(this.db), async () => {
547
+ let tx;
548
+ try {
549
+ tx = await begin.call(this.db);
550
+ } catch (error) {
551
+ if (error instanceof NestedTransactionError) throw unsupported();
552
+ throw error;
553
+ }
554
+ let value;
555
+ try {
556
+ if (isPostgresDatabase(this.db)) await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", JSON.stringify(["smrt.human-resources", this.actor.tenantId]));
557
+ value = await run(tx, (event) => events.push(event));
558
+ } catch (error) {
559
+ await tx.rollback().catch(() => void 0);
560
+ throw error;
561
+ }
562
+ await tx.commit();
563
+ return value;
564
+ }));
565
+ for (const event of events) await this.emit(event);
566
+ return result;
567
+ }
568
+ async emit(event) {
569
+ if (!this.onEvent) return;
570
+ try {
571
+ await this.onEvent(event);
572
+ } catch (error) {
573
+ logger.error(`[smrt-human-resources] onEvent handler failed for ${event.type}`, { error: error instanceof Error ? error.message : String(error) });
574
+ }
575
+ }
576
+ };
577
+ //#endregion
578
+ //#region src/employment/queries.ts
579
+ async function termsCovering(db, tenantId, on) {
580
+ return (await (await EmploymentTermCollection.create({ db })).list({ where: {
581
+ tenantId,
582
+ "startedOn <=": on
583
+ } })).filter((term) => dateWithin(on, term.startedOn, term.endedOn));
584
+ }
585
+ async function employmentsOn(db, tenantId, on) {
586
+ const ids = [...new Set((await termsCovering(db, tenantId, on)).map((t) => t.employmentId))];
587
+ if (ids.length === 0) return [];
588
+ return (await EmploymentCollection.create({ db })).list({ where: {
589
+ tenantId,
590
+ id: ids
591
+ } });
592
+ }
593
+ //#endregion
594
+ //#region src/employment/service.ts
595
+ var WORKER_TYPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
596
+ var TERM_CHANGE_KINDS = [
597
+ "hired",
598
+ "ended",
599
+ "rehired",
600
+ "position-changed",
601
+ "worker-type-changed",
602
+ "leave-started",
603
+ "leave-ended"
604
+ ];
605
+ var LEAVE_CHANGE_KINDS = ["leave-started", "leave-ended"];
606
+ function workerTypeOf(value) {
607
+ if (typeof value !== "string" || !WORKER_TYPE.test(value)) throw new HrError("HR_INVALID", `workerType must be lowercase kebab-case, got '${String(value)}'.`);
608
+ return value;
609
+ }
610
+ function positionOf(value) {
611
+ if (value === null || value === void 0) return null;
612
+ if (typeof value !== "string") throw new HrError("HR_INVALID", "position must be text or null.");
613
+ return value.trim() || null;
614
+ }
615
+ function noteOf(value) {
616
+ if (value === void 0 || value === null) return "";
617
+ if (typeof value !== "string") throw new HrError("HR_INVALID", "A note or reason must be text.");
618
+ return value.trim();
619
+ }
620
+ function createdTime$1(change) {
621
+ const at = change.created_at;
622
+ const time = at ? new Date(at).getTime() : 0;
623
+ return Number.isNaN(time) ? 0 : time;
624
+ }
625
+ function inHistoryOrder(changes) {
626
+ return [...changes].sort((a, b) => a.effectiveOn === b.effectiveOn ? createdTime$1(a) - createdTime$1(b) : a.effectiveOn < b.effectiveOn ? -1 : 1);
627
+ }
628
+ function onLeaveOn(history, on) {
629
+ let onLeave = false;
630
+ for (const change of history) {
631
+ if (change.effectiveOn > on) break;
632
+ if (change.kind === "leave-started") onLeave = true;
633
+ else if (change.kind === "leave-ended" || change.kind === "rehired") onLeave = false;
634
+ }
635
+ return onLeave;
636
+ }
637
+ function valueOn(history, kind, on, current) {
638
+ const ofKind = history.filter((change) => change.kind === kind);
639
+ if (ofKind.length === 0) return current;
640
+ let value = ofKind[0].fromValue;
641
+ for (const change of ofKind) {
642
+ if (change.effectiveOn > on) break;
643
+ value = change.toValue;
644
+ }
645
+ return value;
646
+ }
647
+ var EmploymentService = class extends HrService {
648
+ async load(db, id) {
649
+ const [employment] = await (await EmploymentCollection.create({ db })).list({
650
+ where: {
651
+ tenantId: this.actor.tenantId,
652
+ id: this.id("employmentId", id)
653
+ },
654
+ limit: 1
655
+ });
656
+ if (!employment) throw new HrError("HR_NOT_FOUND", "Employment not found in this tenant.");
657
+ return employment;
658
+ }
659
+ async one(where, db = this.db) {
660
+ const [employment] = await (await EmploymentCollection.create({ db })).list({
661
+ where: {
662
+ tenantId: this.actor.tenantId,
663
+ ...where
664
+ },
665
+ limit: 1
666
+ });
667
+ return employment ?? null;
668
+ }
669
+ async termsOf(db, employmentId) {
670
+ return (await (await EmploymentTermCollection.create({ db })).list({ where: {
671
+ tenantId: this.actor.tenantId,
672
+ employmentId
673
+ } })).sort((a, b) => a.startedOn < b.startedOn ? -1 : a.startedOn > b.startedOn ? 1 : 0);
674
+ }
675
+ async changesOf(db, employmentId) {
676
+ return inHistoryOrder(await (await EmploymentChangeCollection.create({ db })).list({ where: {
677
+ tenantId: this.actor.tenantId,
678
+ employmentId
679
+ } }));
680
+ }
681
+ /**
682
+ * Append a change. Creation instants are strictly increasing within one
683
+ * employment (writes are serialized per tenant), so same-day changes replay
684
+ * in the order they were recorded.
685
+ */
686
+ async record(db, employmentId, change) {
687
+ const latest = (await (await EmploymentChangeCollection.create({ db })).list({ where: {
688
+ tenantId: this.actor.tenantId,
689
+ employmentId
690
+ } })).reduce((max, row) => Math.max(max, createdTime$1(row)), 0);
691
+ await insertHr(EmploymentChange, db, {
692
+ tenantId: this.actor.tenantId,
693
+ employmentId,
694
+ kind: change.kind,
695
+ effectiveOn: change.effectiveOn,
696
+ fromValue: change.fromValue ?? null,
697
+ toValue: change.toValue ?? null,
698
+ note: change.note ?? "",
699
+ actorProfileId: this.actor.profileId,
700
+ created_at: new Date(Math.max(Date.now(), latest + 1))
701
+ });
702
+ }
703
+ /**
704
+ * The term a dated change belongs to, with `effectiveOn` inside it: the
705
+ * latest term, which is the open one until an end is recorded and the final
706
+ * one (up to its last day) afterwards. The stored status plays no part, so
707
+ * a change dated inside a notice period is accepted.
708
+ *
709
+ * @throws HrError `HR_INVALID` before the term started, `HR_STATUS_TRANSITION` after its last day
710
+ */
711
+ async latestTerm(db, employment, effectiveOn) {
712
+ const term = (await this.termsOf(db, employment.id)).at(-1);
713
+ if (!term) throw new HrError("HR_STATUS_TRANSITION", "The employment has no term.");
714
+ if (effectiveOn < term.startedOn) throw new HrError("HR_INVALID", `${effectiveOn} is before the current term started (${term.startedOn}).`);
715
+ if (term.endedOn !== null && effectiveOn > term.endedOn) throw new HrError("HR_STATUS_TRANSITION", `The employment ended on ${term.endedOn}; rehire it first.`);
716
+ return term;
717
+ }
718
+ async notBeforeLatest(db, employmentId, kinds, effectiveOn) {
719
+ const latest = (await this.changesOf(db, employmentId)).filter((change) => kinds.includes(change.kind)).at(-1);
720
+ if (latest && effectiveOn < latest.effectiveOn) throw new HrError("HR_INVALID", `${effectiveOn} is earlier than the latest recorded change (${latest.effectiveOn}).`);
721
+ }
722
+ /**
723
+ * A login is exclusive to the employment that stores it until it is
724
+ * explicitly unlinked there, whatever that employment's stored status. An
725
+ * ended employment keeps its login through its notice period and after, so
726
+ * two employments never share one and `findByUser(userId, on)` cannot be
727
+ * ambiguous through the service.
728
+ */
729
+ async loginFree(db, userId, employmentId) {
730
+ if ((await (await EmploymentCollection.create({ db })).list({ where: {
731
+ tenantId: this.actor.tenantId,
732
+ userId
733
+ } })).some((other) => other.id !== employmentId)) throw new HrError("HR_INVALID", "This login is already linked to another employment; unlink it there first.");
734
+ }
735
+ transition(employment, to) {
736
+ if (!EMPLOYMENT_STATUS_TRANSITIONS[employment.status]?.includes(to)) throw new HrError("HR_STATUS_TRANSITION", `An employment cannot go from '${employment.status}' to '${to}'.`);
737
+ employment.status = to;
738
+ }
739
+ /**
740
+ * Employ a person: creates the employment, its first term and a `hired`
741
+ * change. A profile has one employment per tenant, so a person who worked
742
+ * here before is rehired instead.
743
+ *
744
+ * @throws HrError `HR_ALREADY_EMPLOYED`, `HR_EMPLOYEE_NUMBER_TAKEN`, `HR_INVALID` (including a login already linked to another employment, ended or not: unlink it there first)
745
+ */
746
+ async hire(input) {
747
+ const profileId = this.id("profileId", input?.profileId);
748
+ const employeeNumber = typeof input.employeeNumber === "string" ? input.employeeNumber.trim() : "";
749
+ if (!employeeNumber) throw new HrError("HR_INVALID", "employeeNumber is required.");
750
+ const startedOn = assertIsoDate("startedOn", input.startedOn);
751
+ const workerType = workerTypeOf(input.workerType ?? "employee");
752
+ const position = positionOf(input.position);
753
+ const userId = this.optionalId("userId", input.userId);
754
+ const employerProfileId = this.optionalId("employerProfileId", input.employerProfileId);
755
+ const id = await this.transact(async (db, queue) => {
756
+ if (await this.one({ profileId }, db)) throw new HrError("HR_ALREADY_EMPLOYED", "This profile already has an employment in this tenant; rehire it instead.");
757
+ if (await this.one({ employeeNumber }, db)) throw new HrError("HR_EMPLOYEE_NUMBER_TAKEN", `Employee number '${employeeNumber}' is already used in this tenant.`);
758
+ if (userId) await this.loginFree(db, userId, null);
759
+ const employment = await insertHr(Employment, db, {
760
+ tenantId: this.actor.tenantId,
761
+ profileId,
762
+ userId,
763
+ employerProfileId,
764
+ employeeNumber,
765
+ workerType,
766
+ position,
767
+ status: "active"
768
+ });
769
+ const employmentId = employment.id;
770
+ await insertHr(EmploymentTerm, db, {
771
+ tenantId: this.actor.tenantId,
772
+ employmentId,
773
+ startedOn
774
+ });
775
+ await this.record(db, employmentId, {
776
+ kind: "hired",
777
+ effectiveOn: startedOn
778
+ });
779
+ queue({
780
+ type: "employment.hired",
781
+ employment,
782
+ effectiveOn: startedOn,
783
+ at: /* @__PURE__ */ new Date(),
784
+ byProfileId: this.actor.profileId
785
+ });
786
+ return employmentId;
787
+ });
788
+ return this.get(id);
789
+ }
790
+ /**
791
+ * End an employment: closes the open term on `endedOn` (the last day
792
+ * employed, inclusive) and, in the same transaction, revokes its
793
+ * employment-scoped qualifications from the day after, so they are still
794
+ * good on the last day. `endedOn` cannot be earlier than a position,
795
+ * worker-type or leave change already recorded in the term.
796
+ *
797
+ * `endedOn` may be ahead of the day the end is recorded (a notice period).
798
+ * The stored status becomes `ended` at once, meaning "an end has been
799
+ * recorded": the person is still employed up to and including `endedOn`,
800
+ * so ask by date with {@link check}, {@link employedOn}, {@link asOf} or
801
+ * `findByUser(userId, on)`. Position, worker-type and leave changes dated
802
+ * inside the final term are still accepted.
803
+ *
804
+ * @throws HrError `HR_STATUS_TRANSITION` when already ended, `HR_INVALID`, `HR_NOT_FOUND`
805
+ */
806
+ async end(employmentId, input) {
807
+ const endedOn = assertIsoDate("endedOn", input?.endedOn);
808
+ const reason = noteOf(input.reason);
809
+ const id = await this.transact(async (db, queue) => {
810
+ const employment = await this.load(db, employmentId);
811
+ this.transition(employment, "ended");
812
+ const term = await this.latestTerm(db, employment, endedOn);
813
+ if (term.endedOn !== null) throw new HrError("HR_STATUS_TRANSITION", "The employment has no open term.");
814
+ await this.notBeforeLatest(db, employment.id, TERM_CHANGE_KINDS, endedOn);
815
+ term.endedOn = endedOn;
816
+ term.endReason = reason;
817
+ await persistHr(term);
818
+ await persistHr(employment);
819
+ await this.record(db, employment.id, {
820
+ kind: "ended",
821
+ effectiveOn: endedOn,
822
+ note: reason
823
+ });
824
+ await revokeEmploymentQualifications(db, this.actor, employment.id, addDays(endedOn, 1), queue);
825
+ queue({
826
+ type: "employment.ended",
827
+ employment,
828
+ effectiveOn: endedOn,
829
+ at: /* @__PURE__ */ new Date(),
830
+ byProfileId: this.actor.profileId
831
+ });
832
+ return employment.id;
833
+ });
834
+ return this.get(id);
835
+ }
836
+ /**
837
+ * Rehire an ended employment: adds a term to the same row, so the
838
+ * employment id stays stable. The new term cannot start before a position,
839
+ * worker-type or leave change already recorded. The employment keeps the
840
+ * login it stored, which no other employment can have taken meanwhile.
841
+ *
842
+ * @throws HrError `HR_STATUS_TRANSITION` unless ended, `HR_TERM_OVERLAP`, `HR_INVALID`, `HR_NOT_FOUND`
843
+ */
844
+ async rehire(employmentId, input) {
845
+ const startedOn = assertIsoDate("startedOn", input?.startedOn);
846
+ const workerType = input.workerType === void 0 ? void 0 : workerTypeOf(input.workerType);
847
+ const position = input.position === void 0 ? void 0 : positionOf(input.position);
848
+ const id = await this.transact(async (db, queue) => {
849
+ const employment = await this.load(db, employmentId);
850
+ if (employment.status !== "ended") throw new HrError("HR_STATUS_TRANSITION", `Only an ended employment can be rehired; this one is '${employment.status}'.`);
851
+ const id2 = employment.id;
852
+ const overlapping = (await this.termsOf(db, id2)).find((term) => term.endedOn === null || startedOn <= term.endedOn);
853
+ if (overlapping) throw new HrError("HR_TERM_OVERLAP", `A rehire must start after the previous term ended (${overlapping.endedOn ?? "still open"}).`);
854
+ await this.notBeforeLatest(db, id2, TERM_CHANGE_KINDS, startedOn);
855
+ if (employment.userId) await this.loginFree(db, employment.userId, id2);
856
+ const from = {
857
+ position: employment.position,
858
+ workerType: employment.workerType
859
+ };
860
+ this.transition(employment, "active");
861
+ if (position !== void 0) employment.position = position;
862
+ if (workerType !== void 0) employment.workerType = workerType;
863
+ await persistHr(employment);
864
+ await insertHr(EmploymentTerm, db, {
865
+ tenantId: this.actor.tenantId,
866
+ employmentId: id2,
867
+ startedOn
868
+ });
869
+ await this.record(db, id2, {
870
+ kind: "rehired",
871
+ effectiveOn: startedOn
872
+ });
873
+ if (employment.position !== from.position) await this.record(db, id2, {
874
+ kind: "position-changed",
875
+ effectiveOn: startedOn,
876
+ fromValue: from.position,
877
+ toValue: employment.position
878
+ });
879
+ if (employment.workerType !== from.workerType) await this.record(db, id2, {
880
+ kind: "worker-type-changed",
881
+ effectiveOn: startedOn,
882
+ fromValue: from.workerType,
883
+ toValue: employment.workerType
884
+ });
885
+ queue({
886
+ type: "employment.rehired",
887
+ employment,
888
+ effectiveOn: startedOn,
889
+ at: /* @__PURE__ */ new Date(),
890
+ byProfileId: this.actor.profileId
891
+ });
892
+ return id2;
893
+ });
894
+ return this.get(id);
895
+ }
896
+ /**
897
+ * Record a new position from `effectiveOn`. The employment row holds the
898
+ * latest value; the change row carries the old and new ones.
899
+ *
900
+ * `effectiveOn` must fall inside the latest term. Once an end has been
901
+ * recorded that is the final term, up to and including its `endedOn`, so a
902
+ * change during a notice period is accepted; the stored status stays
903
+ * `ended`.
904
+ *
905
+ * @throws HrError `HR_STATUS_TRANSITION` when `effectiveOn` is after the last day of an ended employment; `HR_INVALID` for a no-op, a date before the term started or before the latest position change; `HR_NOT_FOUND`
906
+ */
907
+ async changePosition(employmentId, input) {
908
+ const effectiveOn = assertIsoDate("effectiveOn", input?.effectiveOn);
909
+ const position = positionOf(input.position);
910
+ const note = noteOf(input.note);
911
+ const id = await this.transact(async (db, queue) => {
912
+ const employment = await this.load(db, employmentId);
913
+ const id2 = employment.id;
914
+ await this.latestTerm(db, employment, effectiveOn);
915
+ if (employment.position === position) throw new HrError("HR_INVALID", "The employment already has this position.");
916
+ await this.notBeforeLatest(db, id2, ["position-changed"], effectiveOn);
917
+ const fromValue = employment.position;
918
+ employment.position = position;
919
+ await persistHr(employment);
920
+ await this.record(db, id2, {
921
+ kind: "position-changed",
922
+ effectiveOn,
923
+ fromValue,
924
+ toValue: position,
925
+ note
926
+ });
927
+ queue({
928
+ type: "employment.position-changed",
929
+ employment,
930
+ effectiveOn,
931
+ at: /* @__PURE__ */ new Date(),
932
+ byProfileId: this.actor.profileId
933
+ });
934
+ return id2;
935
+ });
936
+ return this.get(id);
937
+ }
938
+ /**
939
+ * Record a new worker type from `effectiveOn`. The employment row holds the
940
+ * latest value; the change row carries the old and new ones.
941
+ *
942
+ * `effectiveOn` must fall inside the latest term. Once an end has been
943
+ * recorded that is the final term, up to and including its `endedOn`, so a
944
+ * change during a notice period is accepted; the stored status stays
945
+ * `ended`.
946
+ *
947
+ * @throws HrError `HR_STATUS_TRANSITION` when `effectiveOn` is after the last day of an ended employment; `HR_INVALID` for a no-op, a bad value, a date before the term started or before the latest worker-type change; `HR_NOT_FOUND`
948
+ */
949
+ async changeWorkerType(employmentId, input) {
950
+ const effectiveOn = assertIsoDate("effectiveOn", input?.effectiveOn);
951
+ const workerType = workerTypeOf(input.workerType);
952
+ const note = noteOf(input.note);
953
+ const id = await this.transact(async (db, queue) => {
954
+ const employment = await this.load(db, employmentId);
955
+ const id2 = employment.id;
956
+ await this.latestTerm(db, employment, effectiveOn);
957
+ if (employment.workerType === workerType) throw new HrError("HR_INVALID", "The employment already has this worker type.");
958
+ await this.notBeforeLatest(db, id2, ["worker-type-changed"], effectiveOn);
959
+ const fromValue = employment.workerType;
960
+ employment.workerType = workerType;
961
+ await persistHr(employment);
962
+ await this.record(db, id2, {
963
+ kind: "worker-type-changed",
964
+ effectiveOn,
965
+ fromValue,
966
+ toValue: workerType,
967
+ note
968
+ });
969
+ queue({
970
+ type: "employment.worker-type-changed",
971
+ employment,
972
+ effectiveOn,
973
+ at: /* @__PURE__ */ new Date(),
974
+ byProfileId: this.actor.profileId
975
+ });
976
+ return id2;
977
+ });
978
+ return this.get(id);
979
+ }
980
+ async leave(employmentId, input, to) {
981
+ const effectiveOn = assertIsoDate("effectiveOn", input?.effectiveOn);
982
+ const note = noteOf(input.note);
983
+ const kind = to === "on-leave" ? "leave-started" : "leave-ended";
984
+ const id = await this.transact(async (db, queue) => {
985
+ const employment = await this.load(db, employmentId);
986
+ const id2 = employment.id;
987
+ if (employment.status === "ended") {
988
+ await this.latestTerm(db, employment, effectiveOn);
989
+ await this.notBeforeLatest(db, id2, LEAVE_CHANGE_KINDS, effectiveOn);
990
+ const onLeave = onLeaveOn(await this.changesOf(db, id2), effectiveOn);
991
+ if (onLeave === (to === "on-leave")) throw new HrError("HR_STATUS_TRANSITION", onLeave ? `The employment is already on leave on ${effectiveOn}.` : `The employment is not on leave on ${effectiveOn}.`);
992
+ } else {
993
+ this.transition(employment, to);
994
+ await this.latestTerm(db, employment, effectiveOn);
995
+ await this.notBeforeLatest(db, id2, LEAVE_CHANGE_KINDS, effectiveOn);
996
+ await persistHr(employment);
997
+ }
998
+ await this.record(db, id2, {
999
+ kind,
1000
+ effectiveOn,
1001
+ note
1002
+ });
1003
+ queue({
1004
+ type: `employment.${kind}`,
1005
+ employment,
1006
+ effectiveOn,
1007
+ at: /* @__PURE__ */ new Date(),
1008
+ byProfileId: this.actor.profileId
1009
+ });
1010
+ return id2;
1011
+ });
1012
+ return this.get(id);
1013
+ }
1014
+ /**
1015
+ * Place an employment on leave from `effectiveOn`. A person on leave is
1016
+ * still employed.
1017
+ *
1018
+ * Once an end has been recorded, leave dated inside the final term (up to
1019
+ * and including its `endedOn`) is still accepted: it is recorded as a
1020
+ * change row, the stored status stays `ended`, and whether the person is
1021
+ * already on leave is decided by replaying the history as of `effectiveOn`.
1022
+ *
1023
+ * @throws HrError `HR_STATUS_TRANSITION` when already on leave, or when `effectiveOn` is after the last day of an ended employment; `HR_INVALID` for a date before the term started or before the latest leave change; `HR_NOT_FOUND`
1024
+ */
1025
+ async placeOnLeave(employmentId, input) {
1026
+ return this.leave(employmentId, input, "on-leave");
1027
+ }
1028
+ /**
1029
+ * Return an employment from leave; `effectiveOn` is the first day back.
1030
+ *
1031
+ * Once an end has been recorded, a return dated inside the final term (up
1032
+ * to and including its `endedOn`) is still accepted: it is recorded as a
1033
+ * change row, the stored status stays `ended`, and whether the person is
1034
+ * on leave is decided by replaying the history as of `effectiveOn`.
1035
+ *
1036
+ * @throws HrError `HR_STATUS_TRANSITION` unless on leave, or when `effectiveOn` is after the last day of an ended employment; `HR_INVALID` for a date before the term started or before the latest leave change; `HR_NOT_FOUND`
1037
+ */
1038
+ async returnFromLeave(employmentId, input) {
1039
+ return this.leave(employmentId, input, "active");
1040
+ }
1041
+ async login(employmentId, userId, input) {
1042
+ const effectiveOn = assertIsoDate("effectiveOn", input?.effectiveOn);
1043
+ const id = await this.transact(async (db) => {
1044
+ const employment = await this.load(db, employmentId);
1045
+ const id2 = employment.id;
1046
+ if (employment.userId === userId) throw new HrError("HR_INVALID", userId ? "This login is already linked to the employment." : "The employment has no linked login.");
1047
+ if (userId) await this.loginFree(db, userId, id2);
1048
+ const fromValue = employment.userId;
1049
+ employment.userId = userId;
1050
+ await persistHr(employment);
1051
+ await this.record(db, id2, {
1052
+ kind: userId ? "login-linked" : "login-unlinked",
1053
+ effectiveOn,
1054
+ fromValue,
1055
+ toValue: userId
1056
+ });
1057
+ return id2;
1058
+ });
1059
+ return this.get(id);
1060
+ }
1061
+ /**
1062
+ * Link a login (`smrt-users:User` id) to an employment, replacing any
1063
+ * previous one. Grants nothing by itself: permissions stay with smrt-users.
1064
+ * A login belongs to one employment at a time and stays with it, ended or
1065
+ * not, until {@link unlinkLogin} clears it there.
1066
+ *
1067
+ * The link takes effect at once, whatever `effectiveOn` says: the date is
1068
+ * only recorded on the change row, and login links are not replayed by
1069
+ * {@link asOf} or `findByUser(userId, on)`.
1070
+ *
1071
+ * @throws HrError `HR_INVALID` when already linked to this login, or to another employment (unlink it there first); `HR_NOT_FOUND`
1072
+ */
1073
+ async linkLogin(employmentId, userId, input) {
1074
+ return this.login(employmentId, this.id("userId", userId), input);
1075
+ }
1076
+ /**
1077
+ * Clear an employment's login, which frees it for another employment. Works
1078
+ * on an ended employment too. The login is cleared at once, whatever
1079
+ * `effectiveOn` says; a future date does not keep it linked until then.
1080
+ *
1081
+ * @throws HrError `HR_INVALID` when no login is linked, `HR_NOT_FOUND`
1082
+ */
1083
+ async unlinkLogin(employmentId, input) {
1084
+ return this.login(employmentId, null, input);
1085
+ }
1086
+ /**
1087
+ * One employment in the actor's tenant.
1088
+ *
1089
+ * @throws HrError `HR_NOT_FOUND` when it does not exist in this tenant
1090
+ */
1091
+ async get(employmentId) {
1092
+ return this.scope(() => this.load(this.db, employmentId));
1093
+ }
1094
+ /** The profile's employment in this tenant (ended or not), or null. */
1095
+ async findByProfile(profileId) {
1096
+ if (typeof profileId !== "string" || !profileId.trim()) return null;
1097
+ const id = this.identity(profileId.trim());
1098
+ return this.scope(() => this.one({ profileId: id }));
1099
+ }
1100
+ /** The employment with this employee number in this tenant, or null. */
1101
+ async findByEmployeeNumber(employeeNumber) {
1102
+ if (typeof employeeNumber !== "string" || !employeeNumber.trim()) return null;
1103
+ return this.scope(() => this.one({ employeeNumber: employeeNumber.trim() }));
1104
+ }
1105
+ /**
1106
+ * The employment linked to a login. Fails closed: null when there is none,
1107
+ * or when more than one matches (the service keeps a login on one
1108
+ * employment, so that takes rows written some other way).
1109
+ *
1110
+ * With `on`, the answer comes from the dated terms: the employment linked
1111
+ * to the login that has a term covering `on`, whatever its stored status.
1112
+ * Without `on`, it is the linked employment whose stored status is not
1113
+ * `ended`, which stops resolving as soon as an end is recorded. A host that
1114
+ * records end dates ahead of time (a notice period) should pass the date,
1115
+ * or the worker is locked out before their last day.
1116
+ *
1117
+ * @throws HrError `HR_INVALID` when `on` is given and is not a calendar date
1118
+ */
1119
+ async findByUser(userId, on) {
1120
+ const date = on === void 0 ? null : assertIsoDate("on", on);
1121
+ if (typeof userId !== "string" || !userId.trim()) return null;
1122
+ const id = this.identity(userId.trim());
1123
+ return this.scope(async () => {
1124
+ const linked = await (await EmploymentCollection.create({ db: this.db })).list({ where: {
1125
+ tenantId: this.actor.tenantId,
1126
+ userId: id
1127
+ } });
1128
+ const matching = [];
1129
+ for (const employment of linked) if (date === null ? employment.status !== "ended" : (await this.termsOf(this.db, employment.id)).some((term) => dateWithin(date, term.startedOn, term.endedOn))) matching.push(employment);
1130
+ return matching.length === 1 ? matching[0] : null;
1131
+ });
1132
+ }
1133
+ /** Everyone with a term covering `on`, including people on leave. */
1134
+ async employedOn(on) {
1135
+ const date = assertIsoDate("on", on);
1136
+ return this.scope(() => employmentsOn(this.db, this.actor.tenantId, date));
1137
+ }
1138
+ /**
1139
+ * Whether a profile is employed on a date. Answered from terms, never from
1140
+ * the stored status; `onLeave` is computed as of `on`, so past dates answer
1141
+ * correctly.
1142
+ */
1143
+ async check(profileId, on) {
1144
+ const date = assertIsoDate("on", on);
1145
+ if (typeof profileId !== "string" || !profileId.trim()) return {
1146
+ ok: false,
1147
+ reason: "not-employed"
1148
+ };
1149
+ const id = this.identity(profileId.trim());
1150
+ return this.scope(async () => {
1151
+ const employment = await this.one({ profileId: id });
1152
+ if (!employment) return {
1153
+ ok: false,
1154
+ reason: "not-employed"
1155
+ };
1156
+ const employmentId = employment.id;
1157
+ const terms = await this.termsOf(this.db, employmentId);
1158
+ if (terms.length === 0) return {
1159
+ ok: false,
1160
+ reason: "not-employed"
1161
+ };
1162
+ if (!terms.some((term) => dateWithin(date, term.startedOn, term.endedOn))) return {
1163
+ ok: false,
1164
+ reason: date < terms[0].startedOn ? "not-started" : "ended"
1165
+ };
1166
+ return {
1167
+ ok: true,
1168
+ employmentId,
1169
+ onLeave: onLeaveOn(await this.changesOf(this.db, employmentId), date)
1170
+ };
1171
+ });
1172
+ }
1173
+ /**
1174
+ * An employment's terms, oldest first.
1175
+ *
1176
+ * @throws HrError `HR_NOT_FOUND` when the employment is not in this tenant
1177
+ */
1178
+ async terms(employmentId) {
1179
+ return this.scope(async () => {
1180
+ const employment = await this.load(this.db, employmentId);
1181
+ return this.termsOf(this.db, employment.id);
1182
+ });
1183
+ }
1184
+ /**
1185
+ * An employment's dated changes, by effective date and then in the order
1186
+ * they were recorded.
1187
+ *
1188
+ * @throws HrError `HR_NOT_FOUND` when the employment is not in this tenant
1189
+ */
1190
+ async history(employmentId) {
1191
+ return this.scope(async () => {
1192
+ const employment = await this.load(this.db, employmentId);
1193
+ return this.changesOf(this.db, employment.id);
1194
+ });
1195
+ }
1196
+ /**
1197
+ * An employment's position, worker type and standing on one date, replayed
1198
+ * from the change history. Before the first term the values are those at
1199
+ * hire and `employed` is false.
1200
+ *
1201
+ * @throws HrError `HR_NOT_FOUND` when the employment is not in this tenant
1202
+ */
1203
+ async asOf(employmentId, on) {
1204
+ const date = assertIsoDate("on", on);
1205
+ return this.scope(async () => {
1206
+ const employment = await this.load(this.db, employmentId);
1207
+ const id = employment.id;
1208
+ const history = await this.changesOf(this.db, id);
1209
+ const employed = (await this.termsOf(this.db, id)).some((term) => dateWithin(date, term.startedOn, term.endedOn));
1210
+ return {
1211
+ position: valueOn(history, "position-changed", date, employment.position),
1212
+ workerType: valueOn(history, "worker-type-changed", date, employment.workerType) ?? employment.workerType,
1213
+ employed,
1214
+ onLeave: employed && onLeaveOn(history, date)
1215
+ };
1216
+ });
1217
+ }
1218
+ };
1219
+ //#endregion
1220
+ //#region src/qualifications/service.ts
1221
+ var SUGGESTED_QUALIFICATIONS = Object.freeze([
1222
+ Object.freeze({
1223
+ key: "first-aid",
1224
+ name: "First aid",
1225
+ kind: "certification",
1226
+ expires: true,
1227
+ validityMonths: 36
1228
+ }),
1229
+ Object.freeze({
1230
+ key: "site-orientation",
1231
+ name: "Site orientation",
1232
+ kind: "training"
1233
+ }),
1234
+ Object.freeze({
1235
+ key: "youth-worker",
1236
+ name: "Youth worker",
1237
+ kind: "restriction"
1238
+ })
1239
+ ]);
1240
+ function employmentEnd(endsOn) {
1241
+ return endsOn === null ? null : {
1242
+ on: addDays(endsOn, 1),
1243
+ reason: EMPLOYMENT_ENDED_REASON
1244
+ };
1245
+ }
1246
+ var KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1247
+ var KINDS = [
1248
+ "ticket",
1249
+ "certification",
1250
+ "authorization",
1251
+ "training",
1252
+ "restriction"
1253
+ ];
1254
+ var SCOPES = ["person", "employment"];
1255
+ var IN_CHUNK = 500;
1256
+ function invalid(message) {
1257
+ return new HrError("HR_INVALID", message);
1258
+ }
1259
+ function text(fieldName, value, required = false) {
1260
+ if (value == null && !required) return "";
1261
+ if (typeof value !== "string") throw invalid(`${fieldName} must be text.`);
1262
+ const trimmed = value.trim();
1263
+ if (required && !trimmed) throw invalid(`${fieldName} is required.`);
1264
+ return trimmed;
1265
+ }
1266
+ function flag(fieldName, value, fallback) {
1267
+ if (value === void 0) return fallback;
1268
+ if (typeof value !== "boolean") throw invalid(`${fieldName} must be true or false.`);
1269
+ return value;
1270
+ }
1271
+ function validity(expires, value) {
1272
+ if (value == null) return null;
1273
+ if (!Number.isInteger(value) || value <= 0) throw invalid("validityMonths must be a positive whole number or null.");
1274
+ if (!expires) throw invalid("validityMonths only applies to a qualification that expires.");
1275
+ return value;
1276
+ }
1277
+ function normalizeDefinition(input) {
1278
+ const key = text("key", input?.key, true);
1279
+ if (!KEY.test(key)) throw invalid(`key must be lowercase kebab-case, got '${key}'.`);
1280
+ if (!KINDS.includes(input.kind)) throw invalid(`kind must be one of ${KINDS.join(", ")}.`);
1281
+ const scope = input.scope ?? (input.kind === "authorization" ? "employment" : "person");
1282
+ if (!SCOPES.includes(scope)) throw invalid(`scope must be one of ${SCOPES.join(", ")}.`);
1283
+ const expires = flag("expires", input.expires, false);
1284
+ return {
1285
+ key,
1286
+ name: text("name", input.name, true),
1287
+ kind: input.kind,
1288
+ issuingBody: text("issuingBody", input.issuingBody),
1289
+ expires,
1290
+ validityMonths: validity(expires, input.validityMonths),
1291
+ scope
1292
+ };
1293
+ }
1294
+ function resolveExpiry(definition, issuedOn, given) {
1295
+ if (!definition.expires) {
1296
+ if (given != null) throw invalid(`'${definition.key}' does not expire; omit expiresOn.`);
1297
+ return null;
1298
+ }
1299
+ let expiresOn;
1300
+ if (given != null) expiresOn = assertIsoDate("expiresOn", given);
1301
+ else if (definition.validityMonths != null) expiresOn = addMonths(issuedOn, definition.validityMonths);
1302
+ else throw invalid(`'${definition.key}' expires and has no default validity; give expiresOn.`);
1303
+ if (expiresOn < issuedOn) throw invalid("expiresOn must not be before issuedOn.");
1304
+ return expiresOn;
1305
+ }
1306
+ function standingOn(changes, on) {
1307
+ let suspensions = 0;
1308
+ for (const change of changes) {
1309
+ if (change.effectiveOn > on) continue;
1310
+ if (change.kind === "revoked") return "revoked";
1311
+ if (change.kind === "suspended") suspensions += 1;
1312
+ else if (change.kind === "reinstated") suspensions -= 1;
1313
+ }
1314
+ return suspensions > 0 ? "suspended" : null;
1315
+ }
1316
+ function statusOn(row, changes, on) {
1317
+ if (row.issuedOn > on) return "not-yet-issued";
1318
+ const standing = standingOn(changes, on);
1319
+ if (standing) return standing;
1320
+ if (row.expiresOn != null && on > row.expiresOn) return "expired";
1321
+ return "valid";
1322
+ }
1323
+ function chainStatusesOn(rows, changes, on) {
1324
+ const renewalOf = /* @__PURE__ */ new Map();
1325
+ for (const row of rows) if (row.renewalOfId) renewalOf.set(row.renewalOfId, row);
1326
+ const statuses = /* @__PURE__ */ new Map();
1327
+ for (const row of rows) {
1328
+ const id = row.id;
1329
+ let status = statusOn(row, changes.get(id) ?? [], on);
1330
+ const seen = /* @__PURE__ */ new Set([id]);
1331
+ let later = renewalOf.get(id);
1332
+ while (status !== "revoked" && later && !seen.has(later.id)) {
1333
+ seen.add(later.id);
1334
+ const standing = standingOn(changes.get(later.id) ?? [], on);
1335
+ if (standing && (status === "valid" || status === "suspended")) status = standing;
1336
+ later = renewalOf.get(later.id);
1337
+ }
1338
+ statuses.set(id, status);
1339
+ }
1340
+ return statuses;
1341
+ }
1342
+ var SUSPENSION_KINDS = ["suspended", "reinstated"];
1343
+ function revocationOf(changes) {
1344
+ let earliest = null;
1345
+ for (const change of changes) if (change.kind === "revoked" && (earliest === null || change.effectiveOn < earliest.effectiveOn)) earliest = change;
1346
+ return earliest && {
1347
+ on: earliest.effectiveOn,
1348
+ reason: earliest.reason
1349
+ };
1350
+ }
1351
+ function revokedFrom(changes) {
1352
+ return revocationOf(changes)?.on ?? null;
1353
+ }
1354
+ function latestOf(changes, kinds) {
1355
+ let latest = null;
1356
+ for (const change of changes) if (kinds.includes(change.kind) && (latest === null || change.effectiveOn > latest)) latest = change.effectiveOn;
1357
+ return latest;
1358
+ }
1359
+ function createdTime(row) {
1360
+ const time = row.created_at ? new Date(row.created_at).getTime() : 0;
1361
+ return Number.isNaN(time) ? 0 : time;
1362
+ }
1363
+ function longestLasting(rows) {
1364
+ return rows.reduce((best, row) => {
1365
+ if (best.expiresOn === row.expiresOn) return row.issuedOn > best.issuedOn ? row : best;
1366
+ if (best.expiresOn == null) return best;
1367
+ if (row.expiresOn == null) return row;
1368
+ return row.expiresOn > best.expiresOn ? row : best;
1369
+ });
1370
+ }
1371
+ var QualificationService = class extends HrService {
1372
+ /** Add a qualification definition; its key is unique per tenant. */
1373
+ async define(input) {
1374
+ const definition = normalizeDefinition(input);
1375
+ const id = await this.transact(async (db) => {
1376
+ if (await this.byKey(db, definition.key)) throw new HrError("HR_QUALIFICATION_KEY_TAKEN", `A qualification with key '${definition.key}' already exists.`);
1377
+ return (await this.insertDefinition(db, definition)).id;
1378
+ });
1379
+ return this.get(id);
1380
+ }
1381
+ /** Change a definition's descriptive fields; key and scope are immutable. */
1382
+ async update(qualificationId, input) {
1383
+ const id = this.id("qualificationId", qualificationId);
1384
+ if (input == null || typeof input !== "object") throw invalid("update needs the fields to change.");
1385
+ await this.transact(async (db) => {
1386
+ const row = await this.definition(db, id);
1387
+ if (input.name !== void 0) row.name = text("name", input.name, true);
1388
+ if (input.issuingBody !== void 0) row.issuingBody = text("issuingBody", input.issuingBody);
1389
+ row.expires = flag("expires", input.expires, row.expires);
1390
+ row.isActive = flag("isActive", input.isActive, row.isActive);
1391
+ if (input.validityMonths !== void 0) row.validityMonths = validity(row.expires, input.validityMonths);
1392
+ else if (!row.expires) row.validityMonths = null;
1393
+ await persistHr(row);
1394
+ });
1395
+ return this.get(id);
1396
+ }
1397
+ /**
1398
+ * Create the definitions whose key is missing and leave existing rows
1399
+ * untouched. Returns one definition per input, in input order.
1400
+ */
1401
+ async seed(definitions) {
1402
+ const wanted = definitions.map(normalizeDefinition);
1403
+ if (new Set(wanted.map((d) => d.key)).size !== wanted.length) throw invalid("Seed definitions must have distinct keys.");
1404
+ const ids = await this.transact(async (db) => {
1405
+ const result = [];
1406
+ for (const definition of wanted) {
1407
+ const row = await this.byKey(db, definition.key) ?? await this.insertDefinition(db, definition);
1408
+ result.push(row.id);
1409
+ }
1410
+ return result;
1411
+ });
1412
+ return this.scope(async () => {
1413
+ const rows = await (await QualificationCollection.create({ db: this.db })).list({ where: { tenantId: this.actor.tenantId } });
1414
+ const byId = new Map(rows.map((row) => [row.id, row]));
1415
+ return ids.map((id) => byId.get(id));
1416
+ });
1417
+ }
1418
+ /** The tenant's definitions ordered by key; active ones unless asked. */
1419
+ async list(options = {}) {
1420
+ return this.scope(async () => {
1421
+ const rows = await (await QualificationCollection.create({ db: this.db })).list({
1422
+ where: { tenantId: this.actor.tenantId },
1423
+ orderBy: "key ASC"
1424
+ });
1425
+ return options.includeInactive ? rows : rows.filter((r) => r.isActive);
1426
+ });
1427
+ }
1428
+ /**
1429
+ * One definition by id.
1430
+ *
1431
+ * @throws HrError `HR_NOT_FOUND` outside the actor's tenant, `HR_INVALID` for a blank id
1432
+ */
1433
+ async get(qualificationId) {
1434
+ const id = this.id("qualificationId", qualificationId);
1435
+ return this.scope(() => this.definition(this.db, id));
1436
+ }
1437
+ /** One definition by key, or null. */
1438
+ async findByKey(key) {
1439
+ if (typeof key !== "string" || !key.trim()) return null;
1440
+ return this.scope(() => this.byKey(this.db, key.trim()));
1441
+ }
1442
+ /**
1443
+ * Record that a person holds a qualification from `issuedOn`.
1444
+ *
1445
+ * A person has one live grant of a qualification at a time, decided by
1446
+ * date: the grant is refused unless everything they already hold of it is
1447
+ * revoked effective on or before `issuedOn`. A lapsed ticket (swept or not)
1448
+ * is renewed, not granted again, and a revocation scheduled for a later
1449
+ * date does not free an earlier issue date.
1450
+ *
1451
+ * An employment-scoped grant dated inside a term whose end is already
1452
+ * recorded ends with that employment: the `employment-ended` revocation,
1453
+ * effective the day after the term's last day, is recorded in the same
1454
+ * transaction and the row is stored `revoked`.
1455
+ *
1456
+ * @throws HrError `HR_ALREADY_HELD` unless every earlier grant is revoked effective on or before `issuedOn` (renew the existing one instead), `HR_QUALIFICATION_SCOPE`, `HR_INVALID`, `HR_NOT_FOUND`
1457
+ */
1458
+ async grant(input) {
1459
+ const qualificationId = this.id("qualificationId", input?.qualificationId);
1460
+ const profileId = this.id("profileId", input.profileId);
1461
+ const issuedOn = assertIsoDate("issuedOn", input.issuedOn);
1462
+ const givenEmploymentId = this.optionalId("employmentId", input.employmentId);
1463
+ const id = await this.transact(async (db, queue) => {
1464
+ const definition = await this.grantable(db, qualificationId);
1465
+ const expiresOn = resolveExpiry(definition, issuedOn, input.expiresOn);
1466
+ const { employmentId, endsOn } = await this.employmentFor(db, definition, profileId, givenEmploymentId, issuedOn);
1467
+ const rows = await this.rows(db, {
1468
+ profileId,
1469
+ qualificationId
1470
+ });
1471
+ const [live] = await this.liveChains(db, rows, issuedOn, null);
1472
+ if (live) throw new HrError("HR_ALREADY_HELD", live.revokedOn ? `This person holds '${definition.key}' until its revocation takes effect on ${live.revokedOn}; renew the existing one instead.` : `This person already holds '${definition.key}'; renew it instead.`);
1473
+ return (await this.insertHeld(db, queue, "granted", {
1474
+ qualificationId,
1475
+ profileId,
1476
+ employmentId,
1477
+ revocation: employmentEnd(endsOn),
1478
+ issuedOn,
1479
+ expiresOn,
1480
+ renewalOfId: null,
1481
+ input
1482
+ })).id;
1483
+ });
1484
+ return this.held(this.db, id);
1485
+ }
1486
+ /**
1487
+ * Renew a held qualification: a new row pointing at the old one. The old row
1488
+ * is not modified, so checks on past dates still answer from it. Only the
1489
+ * latest row of a chain can be renewed.
1490
+ *
1491
+ * The renewal is validated against the old row's dated history, not its
1492
+ * stored status: `issuedOn` cannot be earlier than the row's latest
1493
+ * suspension or reinstatement (the renewal would otherwise answer for dates
1494
+ * the history says were suspended), the row must not be suspended on
1495
+ * `issuedOn`, and it must not be revoked on or before `issuedOn`.
1496
+ *
1497
+ * A revocation recorded for a later date (a scheduled one, or the end of an
1498
+ * employment whose last day is ahead) does not stop a renewal: the
1499
+ * qualification is still in force, so a ticket that lapses first can be
1500
+ * renewed. The renewal carries the cutoff forward. It is stored `revoked`
1501
+ * with a revocation at the earlier of the old row's pending revocation
1502
+ * (with its reason) and, for an employment-scoped one inside a term whose
1503
+ * end is recorded, the day after the last day employed
1504
+ * (`employment-ended`); `held-qualification.renewed` and
1505
+ * `held-qualification.revoked` are both raised.
1506
+ *
1507
+ * @throws HrError `HR_STATUS_TRANSITION` when suspended on `issuedOn` (reinstate it first); `HR_ALREADY_HELD` when the person holds another grant outside this chain that is not revoked on or before `issuedOn` and starts before the day this renewal is cut off; `HR_INVALID` for a row revoked on or before `issuedOn`, an already renewed row, an `issuedOn` before the row's own or before its latest suspension or reinstatement, or an expiry earlier than the row's own (revoke it and grant a new one instead); `HR_NOT_FOUND`
1508
+ */
1509
+ async renew(heldQualificationId, input) {
1510
+ const priorId = this.id("heldQualificationId", heldQualificationId);
1511
+ const issuedOn = assertIsoDate("issuedOn", input?.issuedOn);
1512
+ const id = await this.transact(async (db, queue) => {
1513
+ const prior = await this.held(db, priorId);
1514
+ const history = (await this.changesByHeld(db, [prior])).get(priorId) ?? [];
1515
+ const pending = revocationOf(history);
1516
+ if (pending !== null && pending.on <= issuedOn || pending === null && prior.status === "revoked") throw invalid("A qualification revoked on or before the renewal date cannot be renewed; grant a new one.");
1517
+ const siblings = await this.rows(db, {
1518
+ profileId: prior.profileId,
1519
+ qualificationId: prior.qualificationId
1520
+ });
1521
+ if (siblings.some((row2) => row2.renewalOfId === priorId)) throw invalid("This qualification was already renewed; renew the latest one.");
1522
+ if (issuedOn < prior.issuedOn) throw invalid("A renewal cannot be issued before what it renews.");
1523
+ const changedOn = latestOf(history, SUSPENSION_KINDS);
1524
+ if (changedOn !== null && issuedOn < changedOn) throw invalid(`A renewal cannot be issued before the latest suspension or reinstatement on ${changedOn}.`);
1525
+ if (standingOn(history, issuedOn) === "suspended") throw new HrError("HR_STATUS_TRANSITION", "A suspended qualification cannot be renewed; reinstate it first.");
1526
+ const definition = await this.grantable(db, prior.qualificationId);
1527
+ const expiresOn = resolveExpiry(definition, issuedOn, input.expiresOn);
1528
+ if (expiresOn !== null && (prior.expiresOn === null || expiresOn < prior.expiresOn)) throw invalid(`A renewal cannot end before what it renews (${prior.expiresOn ?? "no expiry"}). To replace it with a shorter one, revoke it from ${issuedOn} and grant a new one from that day.`);
1529
+ const { employmentId, endsOn } = await this.employmentFor(db, definition, prior.profileId, prior.employmentId, issuedOn);
1530
+ const ended = employmentEnd(endsOn);
1531
+ const revocation = ended !== null && (pending === null || ended.on <= pending.on) ? ended : pending;
1532
+ const byId = new Map(siblings.map((row2) => [row2.id, row2]));
1533
+ const startsOn = (row2) => {
1534
+ let first = row2.issuedOn;
1535
+ const seen = /* @__PURE__ */ new Set();
1536
+ for (let member = row2; member && !seen.has(member.id); member = member.renewalOfId ? byId.get(member.renewalOfId) : void 0) {
1537
+ seen.add(member.id);
1538
+ if (member.issuedOn < first) first = member.issuedOn;
1539
+ }
1540
+ return first;
1541
+ };
1542
+ if ((await this.liveChains(db, siblings, issuedOn, priorId)).filter((other) => revocation === null || startsOn(other.row) < revocation.on).length > 0) throw new HrError("HR_ALREADY_HELD", "This person holds another grant of this qualification outside this renewal chain that overlaps the renewal; renew that one, or revoke it first.");
1543
+ return (await this.insertHeld(db, queue, "renewed", {
1544
+ qualificationId: prior.qualificationId,
1545
+ profileId: prior.profileId,
1546
+ employmentId,
1547
+ revocation,
1548
+ issuedOn,
1549
+ expiresOn,
1550
+ renewalOfId: priorId,
1551
+ input
1552
+ })).id;
1553
+ });
1554
+ return this.held(this.db, id);
1555
+ }
1556
+ /**
1557
+ * Suspend a qualification from `effectiveOn` until it is reinstated. Act on
1558
+ * the latest row of a renewal chain: the whole chain shares its standing,
1559
+ * so the rows it renewed stop passing checks too.
1560
+ *
1561
+ * Whether it can be suspended is decided from the chain's dated history as
1562
+ * of `effectiveOn`, never from the stored status: it must be neither
1563
+ * suspended nor revoked on that day. A revocation recorded for a later date
1564
+ * (scheduled, or the end of an employment whose last day is ahead) does not
1565
+ * prevent it, and neither does a stored `expired`.
1566
+ *
1567
+ * `effectiveOn` may be any date from the chain's first issue date onward,
1568
+ * not before the chain's latest suspension or reinstatement. It may precede
1569
+ * the latest row's own issue date: with a renewal recorded ahead of time,
1570
+ * that is how the ticket in force today is suspended, and the renewal is
1571
+ * then suspended from its issue date until reinstated.
1572
+ *
1573
+ * The stored status becomes `suspended`, except on a row already stored
1574
+ * `revoked`, which keeps it: only the change row is added.
1575
+ *
1576
+ * @throws HrError `HR_INVALID` for a row that was renewed, a date outside that range or a bad input; `HR_STATUS_TRANSITION` when suspended or revoked on `effectiveOn`; `HR_NOT_FOUND`
1577
+ */
1578
+ async suspend(heldQualificationId, input) {
1579
+ return this.transition(heldQualificationId, "suspended", {
1580
+ effectiveOn: input?.effectiveOn,
1581
+ reason: text("reason", input?.reason, true)
1582
+ });
1583
+ }
1584
+ /**
1585
+ * Lift a suspension from `effectiveOn`, which follows the same date rule as
1586
+ * {@link suspend}. The chain must be suspended on that day by its dated
1587
+ * history, and not yet revoked. The stored status becomes `valid`, or
1588
+ * `expired` when the sweep has recorded the row's lapse; a stored `revoked`
1589
+ * stays.
1590
+ *
1591
+ * @throws HrError `HR_INVALID` for a row that was renewed, a date before the suspension or a bad input; `HR_STATUS_TRANSITION` unless suspended on `effectiveOn`; `HR_NOT_FOUND`
1592
+ */
1593
+ async reinstate(heldQualificationId, input) {
1594
+ return this.transition(heldQualificationId, "reinstated", {
1595
+ effectiveOn: input?.effectiveOn,
1596
+ reason: text("reason", input?.reason)
1597
+ });
1598
+ }
1599
+ /**
1600
+ * Revoke a qualification from `effectiveOn`. Terminal: grant a new one
1601
+ * dated on or after it. Act on the latest row of a renewal chain: the rows
1602
+ * it renewed stop passing checks from the same date; dated before a renewal
1603
+ * recorded ahead of time, the renewal is revoked from its issue date.
1604
+ *
1605
+ * `effectiveOn` may be any date from the chain's first issue date onward,
1606
+ * whatever else is recorded: an urgent revocation may be dated before a
1607
+ * suspension recorded ahead of time, or before a revocation that has not
1608
+ * taken effect yet (a scheduled one, or the end of an employment whose last
1609
+ * day is ahead). The earliest revocation is the one in force. It is refused
1610
+ * only when the chain is already revoked on or before `effectiveOn`.
1611
+ *
1612
+ * The stored status becomes `revoked` at once, meaning a revocation has
1613
+ * been recorded; until `effectiveOn` the qualification still passes
1614
+ * {@link check}. A row already stored `revoked` is not saved again: the
1615
+ * earlier revocation is one more change row, and the event carries its
1616
+ * date.
1617
+ *
1618
+ * @throws HrError `HR_INVALID` for a row that was renewed, a date before the chain was issued or a bad input; `HR_STATUS_TRANSITION` when already revoked on or before `effectiveOn`; `HR_NOT_FOUND`
1619
+ */
1620
+ async revoke(heldQualificationId, input) {
1621
+ return this.transition(heldQualificationId, "revoked", {
1622
+ effectiveOn: input?.effectiveOn,
1623
+ reason: text("reason", input?.reason, true)
1624
+ });
1625
+ }
1626
+ /**
1627
+ * Record that the actor has verified a held qualification's document, now.
1628
+ * Optionally attaches, replaces or removes the document. The status and the
1629
+ * dated history do not change, and no event is raised. Verification is not
1630
+ * a status, so it works whatever the standing, including on a row stored
1631
+ * `revoked` (which may still be in force until its revocation takes effect).
1632
+ *
1633
+ * @throws HrError `HR_INVALID`, `HR_NOT_FOUND`
1634
+ */
1635
+ async verify(heldQualificationId, input = {}) {
1636
+ const id = this.id("heldQualificationId", heldQualificationId);
1637
+ if (input == null || typeof input !== "object") throw invalid("verify takes an options object.");
1638
+ const documentAssetId = input.documentAssetId === void 0 ? void 0 : this.optionalId("documentAssetId", input.documentAssetId);
1639
+ await this.transact(async (db) => {
1640
+ const row = await this.held(db, id);
1641
+ if (documentAssetId !== void 0) row.documentAssetId = documentAssetId;
1642
+ row.verifiedByProfileId = this.actor.profileId;
1643
+ row.verifiedAt = /* @__PURE__ */ new Date();
1644
+ await persistHr(row);
1645
+ });
1646
+ return this.held(this.db, id);
1647
+ }
1648
+ /**
1649
+ * Record the lapse of every qualification whose last day is before `today`
1650
+ * and that was still in force on it: an `expired` change dated the day
1651
+ * after `expiresOn`. The application calls this from its own scheduler;
1652
+ * checks never depend on it. Idempotent: a row is recorded once.
1653
+ *
1654
+ * Which rows lapsed is decided by date, not by the stored status, so a
1655
+ * ticket that runs out while a suspension is in force, or while a
1656
+ * revocation is recorded for a later day (a notice period), is recorded
1657
+ * too. A row revoked on or before the day after `expiresOn` did not lapse:
1658
+ * it was revoked. The stored status becomes `expired` only on a row stored
1659
+ * `valid`; a stored `suspended` or `revoked` stays as it is.
1660
+ *
1661
+ * Every run re-reads the lapsed rows stored `suspended` or `revoked`, a set
1662
+ * that only grows with a tenant's history. Pass `since` (a day on or before
1663
+ * the last day the previous run covered) to look only at rows whose last
1664
+ * day is on or after it.
1665
+ *
1666
+ * Emits `held-qualification.expired`, and returns the row, only for
1667
+ * qualifications that were not renewed.
1668
+ *
1669
+ * @throws HrError `HR_INVALID` for a bad `today` or `since`
1670
+ */
1671
+ async sweepExpired(today, options = {}) {
1672
+ assertIsoDate("today", today);
1673
+ const since = options?.since == null ? null : assertIsoDate("since", options.since);
1674
+ const ids = await this.transact(async (db, queue) => {
1675
+ const candidates = (await this.rows(db, {
1676
+ status: [
1677
+ "valid",
1678
+ "suspended",
1679
+ "revoked"
1680
+ ],
1681
+ "expiresOn <": today,
1682
+ ...since === null ? {} : { "expiresOn >=": since }
1683
+ })).filter((row) => row.expiresOn != null);
1684
+ const changes = await this.changesByHeld(db, candidates);
1685
+ const due = candidates.filter((row) => {
1686
+ const history = changes.get(row.id) ?? [];
1687
+ if (history.some((change) => change.kind === "expired")) return false;
1688
+ const revokedOn = revokedFrom(history);
1689
+ return revokedOn === null || revokedOn > addDays(row.expiresOn, 1);
1690
+ });
1691
+ const superseded = await this.supersededIds(db, due);
1692
+ const emitted = [];
1693
+ for (const row of due) {
1694
+ const effectiveOn = addDays(row.expiresOn, 1);
1695
+ if (row.status === "valid") {
1696
+ row.status = "expired";
1697
+ await persistHr(row);
1698
+ }
1699
+ await this.recordChange(db, row, "expired", effectiveOn, "");
1700
+ if (superseded.has(row.id)) continue;
1701
+ this.announce(queue, "held-qualification.expired", row, effectiveOn);
1702
+ emitted.push(row.id);
1703
+ }
1704
+ return emitted;
1705
+ });
1706
+ return this.scope(async () => {
1707
+ return (await this.rowsById(this.db, ids)).sort((a, b) => (a.expiresOn ?? "").localeCompare(b.expiresOn ?? "") || String(a.id).localeCompare(String(b.id)));
1708
+ });
1709
+ }
1710
+ /**
1711
+ * Whether a person holds a qualification on a calendar date. Computed from
1712
+ * issue and expiry dates and the dated change history, never from the stored
1713
+ * status alone, so it is correct for past dates and without the sweep. A
1714
+ * renewal chain shares its standing: while a renewal is revoked or
1715
+ * suspended, the row it renewed does not pass either. A blank profile or
1716
+ * qualification id is `not-held`.
1717
+ */
1718
+ async check(profileId, qualificationId, on) {
1719
+ assertIsoDate("on", on);
1720
+ const blank = (value) => typeof value !== "string" || !value.trim();
1721
+ if (blank(profileId) || blank(qualificationId)) return {
1722
+ ok: false,
1723
+ reason: "not-held"
1724
+ };
1725
+ const where = {
1726
+ profileId: this.id("profileId", profileId),
1727
+ qualificationId: this.id("qualificationId", qualificationId)
1728
+ };
1729
+ return this.scope(async () => {
1730
+ const rows = await this.rows(this.db, where);
1731
+ if (rows.length === 0) return {
1732
+ ok: false,
1733
+ reason: "not-held"
1734
+ };
1735
+ const statuses = chainStatusesOn(rows, await this.changesByHeld(this.db, rows), on);
1736
+ const good = rows.filter((row) => statuses.get(row.id) === "valid");
1737
+ if (good.length > 0) {
1738
+ const best = longestLasting(good);
1739
+ return {
1740
+ ok: true,
1741
+ heldQualificationId: best.id,
1742
+ expiresOn: best.expiresOn
1743
+ };
1744
+ }
1745
+ const issued = rows.filter((row) => row.issuedOn <= on);
1746
+ if (issued.length === 0) return {
1747
+ ok: false,
1748
+ reason: "not-yet-issued"
1749
+ };
1750
+ const latest = issued.reduce((a, b) => b.issuedOn > a.issuedOn || b.issuedOn === a.issuedOn && createdTime(b) >= createdTime(a) ? b : a);
1751
+ return {
1752
+ ok: false,
1753
+ reason: statuses.get(latest.id)
1754
+ };
1755
+ });
1756
+ }
1757
+ /**
1758
+ * One held qualification per person who passes {@link check} on `on`,
1759
+ * ordered by profile id.
1760
+ */
1761
+ async holders(qualificationId, on, options = {}) {
1762
+ assertIsoDate("on", on);
1763
+ const id = this.id("qualificationId", qualificationId);
1764
+ return this.scope(async () => {
1765
+ const rows = await this.rows(this.db, { qualificationId: id });
1766
+ const statuses = chainStatusesOn(rows, await this.changesByHeld(this.db, rows), on);
1767
+ const employed = options?.employedOnly ? new Set((await employmentsOn(this.db, this.actor.tenantId, on)).map((employment) => employment.profileId)) : null;
1768
+ const good = /* @__PURE__ */ new Map();
1769
+ for (const row of rows) {
1770
+ if (employed && !employed.has(row.profileId)) continue;
1771
+ if (statuses.get(row.id) !== "valid") continue;
1772
+ good.set(row.profileId, [...good.get(row.profileId) ?? [], row]);
1773
+ }
1774
+ return [...good.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, held]) => longestLasting(held));
1775
+ });
1776
+ }
1777
+ /**
1778
+ * Qualifications good on `today` (issued, and not suspended or revoked by
1779
+ * the dated history, exactly as {@link check} decides) whose last day falls
1780
+ * in `[today, today + days]`, soonest first. The stored status plays no
1781
+ * part. A qualification that was already renewed is left out, and
1782
+ * `employedOnly` keeps only people with an employment term covering `today`.
1783
+ */
1784
+ async expiringWithin(days, today, options = {}) {
1785
+ assertIsoDate("today", today);
1786
+ if (!Number.isInteger(days) || days < 0) throw invalid("days must be a whole number, zero or more.");
1787
+ return this.scope(async () => {
1788
+ const rows = await this.rows(this.db, {
1789
+ "issuedOn <=": today,
1790
+ "expiresOn >=": today,
1791
+ "expiresOn <=": addDays(today, days)
1792
+ }, "expiresOn ASC");
1793
+ const superseded = await this.supersededIds(this.db, rows);
1794
+ const changes = await this.changesByHeld(this.db, rows);
1795
+ const employed = options?.employedOnly ? new Set((await employmentsOn(this.db, this.actor.tenantId, today)).map((employment) => employment.profileId)) : null;
1796
+ return rows.filter((row) => !superseded.has(row.id) && statusOn(row, changes.get(row.id) ?? [], today) === "valid" && (!employed || employed.has(row.profileId)));
1797
+ });
1798
+ }
1799
+ /**
1800
+ * Everything a person holds (renewed rows are replaced by their renewal),
1801
+ * each with its definition and its status computed for `on`. The status
1802
+ * agrees with {@link check}: it is `valid` when the row, or a row it renews,
1803
+ * is good on `on`, and otherwise the status of the latest row of the chain
1804
+ * issued by `on`. Ordered by qualification key, then issue date.
1805
+ *
1806
+ * @throws HrError `HR_INVALID` for a blank profile id or a bad date
1807
+ */
1808
+ async listForProfile(profileId, on) {
1809
+ assertIsoDate("on", on);
1810
+ const id = this.id("profileId", profileId);
1811
+ return this.scope(async () => {
1812
+ const rows = await this.rows(this.db, { profileId: id });
1813
+ const superseded = await this.supersededIds(this.db, rows);
1814
+ const statuses = chainStatusesOn(rows, await this.changesByHeld(this.db, rows), on);
1815
+ const byRowId = new Map(rows.map((row) => [row.id, row]));
1816
+ const chainStatus = (held) => {
1817
+ let answer = "not-yet-issued";
1818
+ const seen = /* @__PURE__ */ new Set();
1819
+ let earlier = held;
1820
+ while (earlier && !seen.has(earlier.id)) {
1821
+ seen.add(earlier.id);
1822
+ const status = statuses.get(earlier.id);
1823
+ if (status === "valid") return "valid";
1824
+ if (answer === "not-yet-issued") answer = status;
1825
+ earlier = earlier.renewalOfId ? byRowId.get(earlier.renewalOfId) : void 0;
1826
+ }
1827
+ return answer;
1828
+ };
1829
+ const definitions = await QualificationCollection.create({ db: this.db });
1830
+ const byId = new Map((await definitions.list({ where: { tenantId: this.actor.tenantId } })).map((definition) => [definition.id, definition]));
1831
+ return rows.filter((row) => !superseded.has(row.id)).map((held) => ({
1832
+ held,
1833
+ qualification: byId.get(held.qualificationId),
1834
+ status: chainStatus(held)
1835
+ })).sort((a, b) => a.qualification.key.localeCompare(b.qualification.key) || a.held.issuedOn.localeCompare(b.held.issuedOn) || createdTime(a.held) - createdTime(b.held));
1836
+ });
1837
+ }
1838
+ /**
1839
+ * The dated changes of one held qualification, oldest first.
1840
+ *
1841
+ * @throws HrError `HR_NOT_FOUND` outside the actor's tenant, `HR_INVALID` for a blank id
1842
+ */
1843
+ async history(heldQualificationId) {
1844
+ const id = this.id("heldQualificationId", heldQualificationId);
1845
+ return this.scope(async () => {
1846
+ const row = await this.held(this.db, id);
1847
+ return (await this.changesByHeld(this.db, [row])).get(id) ?? [];
1848
+ });
1849
+ }
1850
+ async byKey(db, key) {
1851
+ const [row] = await (await QualificationCollection.create({ db })).list({
1852
+ where: {
1853
+ tenantId: this.actor.tenantId,
1854
+ key
1855
+ },
1856
+ limit: 1
1857
+ });
1858
+ return row ?? null;
1859
+ }
1860
+ async definition(db, id) {
1861
+ const [row] = await (await QualificationCollection.create({ db })).list({
1862
+ where: {
1863
+ tenantId: this.actor.tenantId,
1864
+ id
1865
+ },
1866
+ limit: 1
1867
+ });
1868
+ if (!row) throw new HrError("HR_NOT_FOUND", "Qualification not found.");
1869
+ return row;
1870
+ }
1871
+ async grantable(db, id) {
1872
+ const definition = await this.definition(db, id);
1873
+ if (!definition.isActive) throw invalid(`'${definition.key}' is inactive and cannot be granted.`);
1874
+ return definition;
1875
+ }
1876
+ insertDefinition(db, definition) {
1877
+ return insertHr(Qualification, db, {
1878
+ tenantId: this.actor.tenantId,
1879
+ ...definition
1880
+ });
1881
+ }
1882
+ async held(db, id) {
1883
+ const [row] = await this.scope(() => this.rows(db, { id }));
1884
+ if (!row) throw new HrError("HR_NOT_FOUND", "Held qualification not found.");
1885
+ return row;
1886
+ }
1887
+ /** Held rows in the actor's tenant matching `where`. */
1888
+ async rows(db, where, orderBy) {
1889
+ return (await HeldQualificationCollection.create({ db })).list({
1890
+ where: {
1891
+ tenantId: this.actor.tenantId,
1892
+ ...where
1893
+ },
1894
+ orderBy
1895
+ });
1896
+ }
1897
+ async rowsById(db, ids) {
1898
+ const rows = [];
1899
+ for (let at = 0; at < ids.length; at += IN_CHUNK) rows.push(...await this.rows(db, { id: ids.slice(at, at + IN_CHUNK) }));
1900
+ return rows;
1901
+ }
1902
+ /**
1903
+ * The chains among `rows` (everything one person holds of one
1904
+ * qualification) that are not revoked effective on or before `on`, each
1905
+ * represented by its latest row and the date its revocation takes effect,
1906
+ * if one is recorded. The chain whose latest row is `exceptId` is skipped.
1907
+ * Decided from the dated history: a revocation stored now but effective
1908
+ * later still counts as held.
1909
+ */
1910
+ async liveChains(db, rows, on, exceptId) {
1911
+ const renewed = new Set(rows.map((row) => row.renewalOfId));
1912
+ const latest = rows.filter((row) => !renewed.has(row.id) && row.id !== exceptId);
1913
+ const changes = await this.changesByHeld(db, latest);
1914
+ return latest.map((row) => ({
1915
+ row,
1916
+ revokedOn: revokedFrom(changes.get(row.id) ?? [])
1917
+ })).filter(({ revokedOn }) => revokedOn === null || revokedOn > on);
1918
+ }
1919
+ /** Ids, among `rows`, that a later row renews. */
1920
+ async supersededIds(db, rows) {
1921
+ const ids = rows.map((row) => row.id);
1922
+ const superseded = /* @__PURE__ */ new Set();
1923
+ for (let at = 0; at < ids.length; at += IN_CHUNK) for (const renewal of await this.rows(db, { renewalOfId: ids.slice(at, at + IN_CHUNK) })) if (renewal.renewalOfId) superseded.add(renewal.renewalOfId);
1924
+ return superseded;
1925
+ }
1926
+ /** Change rows per held qualification, oldest first. */
1927
+ async changesByHeld(db, rows) {
1928
+ const ids = rows.map((row) => row.id);
1929
+ const collection = await HeldQualificationChangeCollection.create({ db });
1930
+ const all = [];
1931
+ for (let at = 0; at < ids.length; at += IN_CHUNK) all.push(...await collection.list({ where: {
1932
+ tenantId: this.actor.tenantId,
1933
+ heldQualificationId: ids.slice(at, at + IN_CHUNK)
1934
+ } }));
1935
+ all.sort((a, b) => a.effectiveOn.localeCompare(b.effectiveOn) || createdTime(a) - createdTime(b));
1936
+ const grouped = /* @__PURE__ */ new Map();
1937
+ for (const change of all) grouped.set(change.heldQualificationId, [...grouped.get(change.heldQualificationId) ?? [], change]);
1938
+ return grouped;
1939
+ }
1940
+ /**
1941
+ * The employment an employment-scoped grant belongs to (null for a
1942
+ * person-scoped one) and, when the term covering `issuedOn` already has its
1943
+ * end recorded, that term's last day. The person must be employed on
1944
+ * `issuedOn`.
1945
+ */
1946
+ async employmentFor(db, definition, profileId, given, issuedOn) {
1947
+ const mismatch = (message) => new HrError("HR_QUALIFICATION_SCOPE", message);
1948
+ if (definition.scope !== "employment") {
1949
+ if (given != null) throw mismatch(`'${definition.key}' belongs to the person; omit employmentId.`);
1950
+ return {
1951
+ employmentId: null,
1952
+ endsOn: null
1953
+ };
1954
+ }
1955
+ const [employment] = await (await EmploymentCollection.create({ db })).list({
1956
+ where: {
1957
+ tenantId: this.actor.tenantId,
1958
+ profileId,
1959
+ ...given != null ? { id: given } : {}
1960
+ },
1961
+ limit: 1
1962
+ });
1963
+ if (!employment) throw mismatch(`'${definition.key}' belongs to an employment, and this person has no matching employment.`);
1964
+ const term = (await (await EmploymentTermCollection.create({ db })).list({ where: {
1965
+ tenantId: this.actor.tenantId,
1966
+ employmentId: employment.id,
1967
+ "startedOn <=": issuedOn
1968
+ } })).find((covering) => dateWithin(issuedOn, covering.startedOn, covering.endedOn));
1969
+ if (!term) throw mismatch(`'${definition.key}' belongs to an employment, and this person is not employed on ${issuedOn}.`);
1970
+ return {
1971
+ employmentId: employment.id,
1972
+ endsOn: term.endedOn
1973
+ };
1974
+ }
1975
+ /**
1976
+ * Insert a granted or renewed row with its change and event. With a
1977
+ * `revocation` (the end of an employment term that is already closed, or a
1978
+ * pending revocation a renewal carries forward) the row starts out cut off,
1979
+ * exactly as `EmploymentService.end()` leaves the rows that existed when
1980
+ * the end was recorded: stored `revoked`, a revocation on that date, and
1981
+ * the revoked event.
1982
+ */
1983
+ async insertHeld(db, queue, kind, values) {
1984
+ const { input, revocation, ...identityFields } = values;
1985
+ const verified = flag("verified", input.verified, false);
1986
+ const row = await insertHr(HeldQualification, db, {
1987
+ tenantId: this.actor.tenantId,
1988
+ ...identityFields,
1989
+ certificateNumber: text("certificateNumber", input.certificateNumber),
1990
+ documentAssetId: this.optionalId("documentAssetId", input.documentAssetId),
1991
+ status: revocation === null ? "valid" : "revoked",
1992
+ verifiedByProfileId: verified ? this.actor.profileId : null,
1993
+ verifiedAt: verified ? /* @__PURE__ */ new Date() : null
1994
+ });
1995
+ await this.recordChange(db, row, kind, values.issuedOn, "");
1996
+ this.announce(queue, `held-qualification.${kind}`, row, values.issuedOn);
1997
+ if (revocation !== null) {
1998
+ await this.recordChange(db, row, "revoked", revocation.on, revocation.reason);
1999
+ this.announce(queue, "held-qualification.revoked", row, revocation.on);
2000
+ }
2001
+ return row;
2002
+ }
2003
+ /**
2004
+ * Suspend, reinstate or revoke the latest row of a chain. The change is
2005
+ * validated against the chain's dated history as of `effectiveOn`; the
2006
+ * stored status is not consulted, except that a row stored `revoked` is
2007
+ * never saved again and gets the change row only.
2008
+ */
2009
+ async transition(heldQualificationId, kind, input) {
2010
+ const id = this.id("heldQualificationId", heldQualificationId);
2011
+ const effectiveOn = assertIsoDate("effectiveOn", input.effectiveOn);
2012
+ await this.transact(async (db, queue) => {
2013
+ const row = await this.held(db, id);
2014
+ const siblings = await this.rows(db, {
2015
+ profileId: row.profileId,
2016
+ qualificationId: row.qualificationId
2017
+ });
2018
+ if (siblings.some((other) => other.renewalOfId === id)) throw invalid(`This qualification was renewed and cannot be ${kind} by itself; act on the latest row in its renewal chain.`);
2019
+ const byId = new Map(siblings.map((other) => [other.id, other]));
2020
+ const chain = [];
2021
+ for (let member = row; member && !chain.includes(member); member = member.renewalOfId ? byId.get(member.renewalOfId) : void 0) chain.push(member);
2022
+ const firstIssuedOn = chain.reduce((first, member) => member.issuedOn < first ? member.issuedOn : first, row.issuedOn);
2023
+ if (effectiveOn < firstIssuedOn) throw invalid(`effectiveOn must not be before the qualification was issued on ${firstIssuedOn}.`);
2024
+ const changes = await this.changesByHeld(db, chain);
2025
+ const history = chain.flatMap((member) => changes.get(member.id) ?? []);
2026
+ if (kind === "revoked") {
2027
+ const revokedOn = revokedFrom(history);
2028
+ if (revokedOn !== null && revokedOn <= effectiveOn) throw new HrError("HR_STATUS_TRANSITION", `This qualification is already revoked from ${revokedOn}.`);
2029
+ } else {
2030
+ const changedOn = latestOf(history, SUSPENSION_KINDS);
2031
+ if (changedOn !== null && effectiveOn < changedOn) throw invalid(`effectiveOn must not be before the latest suspension or reinstatement on ${changedOn}.`);
2032
+ const standing = standingOn(history, effectiveOn) ?? "valid";
2033
+ if (standing !== (kind === "suspended" ? "valid" : "suspended")) throw new HrError("HR_STATUS_TRANSITION", standing === "valid" ? `A qualification that is not suspended on ${effectiveOn} cannot be reinstated.` : `A qualification that is ${standing} on ${effectiveOn} cannot be ${kind}.`);
2034
+ }
2035
+ if (row.status !== "revoked") {
2036
+ const lapsed = (changes.get(id) ?? []).some((change) => change.kind === "expired");
2037
+ row.status = kind === "reinstated" ? lapsed ? "expired" : "valid" : kind;
2038
+ await persistHr(row);
2039
+ }
2040
+ await this.recordChange(db, row, kind, effectiveOn, input.reason);
2041
+ this.announce(queue, `held-qualification.${kind}`, row, effectiveOn);
2042
+ });
2043
+ return this.held(this.db, id);
2044
+ }
2045
+ async recordChange(db, row, kind, effectiveOn, reason) {
2046
+ await insertHr(HeldQualificationChange, db, {
2047
+ tenantId: this.actor.tenantId,
2048
+ heldQualificationId: row.id,
2049
+ kind,
2050
+ effectiveOn,
2051
+ reason,
2052
+ actorProfileId: this.actor.profileId
2053
+ });
2054
+ }
2055
+ announce(queue, type, heldQualification, effectiveOn) {
2056
+ queue({
2057
+ type,
2058
+ heldQualification,
2059
+ effectiveOn,
2060
+ at: /* @__PURE__ */ new Date(),
2061
+ byProfileId: this.actor.profileId
2062
+ });
2063
+ }
2064
+ };
2065
+ //#endregion
2066
+ export { EMPLOYMENT_STATUS_TRANSITIONS, Employment, EmploymentChange, EmploymentChangeCollection, EmploymentCollection, EmploymentService, EmploymentTerm, EmploymentTermCollection, HUMAN_RESOURCES_MODULE_META, HUMAN_RESOURCES_UI_SLOTS, HeldQualification, HeldQualificationChange, HeldQualificationChangeCollection, HeldQualificationCollection, HrError, Qualification, QualificationCollection, QualificationService, SUGGESTED_QUALIFICATIONS, SUGGESTED_WORKER_TYPES, addDays, addMonths, assertIsoDate, isIsoDate };
2067
+
2068
+ //# sourceMappingURL=index.js.map