@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.
- package/AGENTS.md +297 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +291 -2
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/dates.d.ts +25 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/employment/index.d.ts +3 -0
- package/dist/employment/index.d.ts.map +1 -0
- package/dist/employment/models.d.ts +73 -0
- package/dist/employment/models.d.ts.map +1 -0
- package/dist/employment/queries.d.ts +12 -0
- package/dist/employment/queries.d.ts.map +1 -0
- package/dist/employment/service.d.ts +277 -0
- package/dist/employment/service.d.ts.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2068 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +2424 -0
- package/dist/qualifications/index.d.ts +3 -0
- package/dist/qualifications/index.d.ts.map +1 -0
- package/dist/qualifications/internal.d.ts +24 -0
- package/dist/qualifications/internal.d.ts.map +1 -0
- package/dist/qualifications/models.d.ts +77 -0
- package/dist/qualifications/models.d.ts.map +1 -0
- package/dist/qualifications/service.d.ts +381 -0
- package/dist/qualifications/service.d.ts.map +1 -0
- package/dist/service-base.d.ts +47 -0
- package/dist/service-base.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +902 -0
- package/dist/svelte/components/EmployeeForm.svelte +369 -0
- package/dist/svelte/components/EmployeeForm.svelte.d.ts +28 -0
- package/dist/svelte/components/EmployeeForm.svelte.d.ts.map +1 -0
- package/dist/svelte/components/EmployeeList.svelte +158 -0
- package/dist/svelte/components/EmployeeList.svelte.d.ts +13 -0
- package/dist/svelte/components/EmployeeList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ExpiringQualificationsList.svelte +126 -0
- package/dist/svelte/components/ExpiringQualificationsList.svelte.d.ts +13 -0
- package/dist/svelte/components/ExpiringQualificationsList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PersonQualifications.svelte +156 -0
- package/dist/svelte/components/PersonQualifications.svelte.d.ts +15 -0
- package/dist/svelte/components/PersonQualifications.svelte.d.ts.map +1 -0
- package/dist/svelte/i18n.d.ts +67 -0
- package/dist/svelte/i18n.d.ts.map +1 -0
- package/dist/svelte/i18n.js +67 -0
- package/dist/svelte/index.d.ts +23 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +25 -0
- package/dist/svelte/types.d.ts +225 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +257 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +17 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +65 -0
- package/dist/ui.js.map +1 -0
- package/dist/write.d.ts +22 -0
- package/dist/write.d.ts.map +1 -0
- 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
|