@kernhq/module-hr 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/approvals.d.ts +281 -0
- package/dist/contract/approvals.d.ts.map +1 -0
- package/dist/contract/approvals.js +127 -0
- package/dist/contract/approvals.js.map +1 -0
- package/dist/contract/capabilities.d.ts +1 -1
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +44 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +42 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +42 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +2 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +2 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/leave.d.ts +168 -0
- package/dist/contract/leave.d.ts.map +1 -0
- package/dist/contract/leave.js +150 -0
- package/dist/contract/leave.js.map +1 -0
- package/dist/contract/permissions.d.ts +57 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +69 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1446 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +235 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +1646 -58
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +812 -2
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +2084 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +209 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +163 -0
- package/dist/server/services/approvals.d.ts.map +1 -0
- package/dist/server/services/approvals.js +325 -0
- package/dist/server/services/approvals.js.map +1 -0
- package/dist/server/services/ledger.d.ts +135 -0
- package/dist/server/services/ledger.d.ts.map +1 -0
- package/dist/server/services/ledger.js +178 -0
- package/dist/server/services/ledger.js.map +1 -0
- package/dist/server/services/people.d.ts +3 -3
- package/migrations/0002_leave.sql +237 -0
- package/migrations/meta/0002_snapshot.json +3009 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/contract/approvals.ts +149 -0
- package/src/contract/capabilities.ts +44 -0
- package/src/contract/events.ts +57 -0
- package/src/contract/index.ts +2 -0
- package/src/contract/leave.ts +171 -0
- package/src/contract/permissions.ts +71 -0
- package/src/contract/router.ts +285 -0
package/dist/server/router.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { KernError, requires, requiresCapability, uuidv7, workspaceScoped, } from '@kernhq/kernel';
|
|
2
2
|
import { implement } from '@orpc/server';
|
|
3
|
-
import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, sql } from 'drizzle-orm';
|
|
3
|
+
import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, or, sql } from 'drizzle-orm';
|
|
4
4
|
import { HrSettings, hrContract, hrEvents, MODULE_ID } from '../contract/index.js';
|
|
5
5
|
import { countWorkingDays, workingDays } from '../policy/calendar.js';
|
|
6
6
|
import { COUNTRY_PACKS, packDays } from './packs/index.js';
|
|
7
|
-
import { calendarDays, calendars, costCenters, customFieldDefs, employments, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, personDocuments, personHistory, positions, } from './schema.js';
|
|
7
|
+
import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, personDocuments, personHistory, positions, } from './schema.js';
|
|
8
|
+
import { ApprovalService } from './services/approvals.js';
|
|
8
9
|
import { inForceOn, todayIso } from './services/db.js';
|
|
10
|
+
import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
|
|
9
11
|
import { PeopleService } from './services/people.js';
|
|
10
12
|
import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
|
|
11
13
|
const os = implement(hrContract).$context();
|
|
@@ -26,6 +28,8 @@ export function implement_(kernel) {
|
|
|
26
28
|
const cap = (id) => requiresCapability(MODULE_ID, id);
|
|
27
29
|
const resolve = new ResolveService();
|
|
28
30
|
const svc = new PeopleService(kernel);
|
|
31
|
+
const ledger = new LedgerService();
|
|
32
|
+
const approvals = new ApprovalService(kernel);
|
|
29
33
|
const db = kernel.database;
|
|
30
34
|
const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
|
|
31
35
|
const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
|
|
@@ -1220,6 +1224,569 @@ export function implement_(kernel) {
|
|
|
1220
1224
|
return { ok: true };
|
|
1221
1225
|
}),
|
|
1222
1226
|
},
|
|
1227
|
+
// ================================================================= leave
|
|
1228
|
+
leave: {
|
|
1229
|
+
types: {
|
|
1230
|
+
list: scoped.leave.types.list
|
|
1231
|
+
.use(cap('leave'))
|
|
1232
|
+
.use(requires('hr.leave.view'))
|
|
1233
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1234
|
+
const where = [eq(leaveTypes.workspaceId, input.workspaceId)];
|
|
1235
|
+
if (!input.includeArchived)
|
|
1236
|
+
where.push(isNull(leaveTypes.archivedAt));
|
|
1237
|
+
const rows = await tx
|
|
1238
|
+
.select()
|
|
1239
|
+
.from(leaveTypes)
|
|
1240
|
+
.where(and(...where))
|
|
1241
|
+
.orderBy(asc(leaveTypes.order), asc(leaveTypes.name));
|
|
1242
|
+
return rows.map(toLeaveType);
|
|
1243
|
+
})),
|
|
1244
|
+
create: scoped.leave.types.create
|
|
1245
|
+
.use(cap('leave'))
|
|
1246
|
+
.use(requires('hr.leave.manage'))
|
|
1247
|
+
.handler(async ({ input }) => {
|
|
1248
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1249
|
+
const [created] = await tx
|
|
1250
|
+
.insert(leaveTypes)
|
|
1251
|
+
.values({
|
|
1252
|
+
id: uuidv7(),
|
|
1253
|
+
workspaceId: input.workspaceId,
|
|
1254
|
+
key: input.key,
|
|
1255
|
+
name: input.name,
|
|
1256
|
+
paid: input.paid,
|
|
1257
|
+
unit: input.unit,
|
|
1258
|
+
color: input.color ?? null,
|
|
1259
|
+
icon: input.icon ?? null,
|
|
1260
|
+
requiresDocumentAfterDays: input.requiresDocumentAfterDays ?? null,
|
|
1261
|
+
countsWorkingDaysOnly: input.countsWorkingDaysOnly,
|
|
1262
|
+
allowNegative: input.allowNegative,
|
|
1263
|
+
maxNegativeMinutes: input.maxNegativeMinutes,
|
|
1264
|
+
})
|
|
1265
|
+
.returning();
|
|
1266
|
+
return created;
|
|
1267
|
+
});
|
|
1268
|
+
await changed(input.workspaceId, 'leave_type', row.id, 'created');
|
|
1269
|
+
return toLeaveType(row);
|
|
1270
|
+
}),
|
|
1271
|
+
update: scoped.leave.types.update
|
|
1272
|
+
.use(cap('leave'))
|
|
1273
|
+
.use(requires('hr.leave.manage'))
|
|
1274
|
+
.handler(async ({ input }) => {
|
|
1275
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1276
|
+
const { workspaceId, leaveTypeId, ...patch } = input;
|
|
1277
|
+
const set = { updatedAt: new Date() };
|
|
1278
|
+
for (const [k, v] of Object.entries(patch))
|
|
1279
|
+
if (v !== undefined)
|
|
1280
|
+
set[k] = v;
|
|
1281
|
+
const [updated] = await tx
|
|
1282
|
+
.update(leaveTypes)
|
|
1283
|
+
.set(set)
|
|
1284
|
+
.where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, leaveTypeId)))
|
|
1285
|
+
.returning();
|
|
1286
|
+
if (!updated)
|
|
1287
|
+
throw KernError.notFound('Leave type');
|
|
1288
|
+
return updated;
|
|
1289
|
+
});
|
|
1290
|
+
await changed(input.workspaceId, 'leave_type', row.id, 'updated');
|
|
1291
|
+
return toLeaveType(row);
|
|
1292
|
+
}),
|
|
1293
|
+
archive: scoped.leave.types.archive
|
|
1294
|
+
.use(cap('leave'))
|
|
1295
|
+
.use(requires('hr.leave.manage'))
|
|
1296
|
+
.handler(async ({ input }) => {
|
|
1297
|
+
// Archived, never deleted: the ledger points at it, and a balance whose type has
|
|
1298
|
+
// vanished is a number nobody can explain.
|
|
1299
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
1300
|
+
.update(leaveTypes)
|
|
1301
|
+
.set({ archivedAt: new Date() })
|
|
1302
|
+
.where(and(eq(leaveTypes.workspaceId, input.workspaceId), eq(leaveTypes.id, input.leaveTypeId))));
|
|
1303
|
+
await changed(input.workspaceId, 'leave_type', input.leaveTypeId, 'deleted');
|
|
1304
|
+
return { ok: true };
|
|
1305
|
+
}),
|
|
1306
|
+
},
|
|
1307
|
+
balance: {
|
|
1308
|
+
get: scoped.leave.balance.get
|
|
1309
|
+
.use(cap('leave'))
|
|
1310
|
+
.use(requires('hr.leave.view'))
|
|
1311
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1312
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1313
|
+
const year = input.periodYear ?? new Date().getUTCFullYear();
|
|
1314
|
+
return ledger.balances(tx, input.workspaceId, personId, year);
|
|
1315
|
+
})),
|
|
1316
|
+
},
|
|
1317
|
+
ledger: {
|
|
1318
|
+
list: scoped.leave.ledger.list
|
|
1319
|
+
.use(cap('leave'))
|
|
1320
|
+
.use(requires('hr.leave.view_ledger'))
|
|
1321
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1322
|
+
const where = [
|
|
1323
|
+
eq(leaveLedger.workspaceId, input.workspaceId),
|
|
1324
|
+
eq(leaveLedger.personId, input.personId),
|
|
1325
|
+
];
|
|
1326
|
+
if (input.leaveTypeId)
|
|
1327
|
+
where.push(eq(leaveLedger.leaveTypeId, input.leaveTypeId));
|
|
1328
|
+
if (input.periodYear)
|
|
1329
|
+
where.push(eq(leaveLedger.periodYear, input.periodYear));
|
|
1330
|
+
const rows = await tx
|
|
1331
|
+
.select()
|
|
1332
|
+
.from(leaveLedger)
|
|
1333
|
+
.where(and(...where))
|
|
1334
|
+
.orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.createdAt))
|
|
1335
|
+
.limit(input.limit);
|
|
1336
|
+
return { items: rows.map(toLedgerEntry), nextCursor: null };
|
|
1337
|
+
})),
|
|
1338
|
+
},
|
|
1339
|
+
adjust: scoped.leave.adjust
|
|
1340
|
+
.use(cap('leave'))
|
|
1341
|
+
.use(requires('hr.leave.adjust'))
|
|
1342
|
+
.handler(async ({ input, context }) => {
|
|
1343
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1344
|
+
const year = yearOf(input.effectiveOn);
|
|
1345
|
+
await ledger.lockAndRead(tx, input.workspaceId, input.personId, input.leaveTypeId, year);
|
|
1346
|
+
return ledger.append(tx, input.workspaceId, {
|
|
1347
|
+
personId: input.personId,
|
|
1348
|
+
leaveTypeId: input.leaveTypeId,
|
|
1349
|
+
kind: input.kind,
|
|
1350
|
+
amountMinutes: input.amountMinutes,
|
|
1351
|
+
effectiveOn: input.effectiveOn,
|
|
1352
|
+
periodYear: year,
|
|
1353
|
+
reason: input.reason,
|
|
1354
|
+
createdBy: context.principal.userId ?? null,
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
await kernel.emit(hrEvents.leaveBalanceChanged, {
|
|
1358
|
+
workspaceId: input.workspaceId,
|
|
1359
|
+
personId: input.personId,
|
|
1360
|
+
leaveTypeId: input.leaveTypeId,
|
|
1361
|
+
deltaMinutes: input.amountMinutes,
|
|
1362
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1363
|
+
await changed(input.workspaceId, 'leave_balance', input.personId, 'updated');
|
|
1364
|
+
return toLedgerEntry(row);
|
|
1365
|
+
}),
|
|
1366
|
+
requests: {
|
|
1367
|
+
list: scoped.leave.requests.list
|
|
1368
|
+
.use(cap('leave'))
|
|
1369
|
+
.use(requires('hr.leave.view'))
|
|
1370
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1371
|
+
const where = [eq(leaveRequests.workspaceId, input.workspaceId)];
|
|
1372
|
+
if (input.personId)
|
|
1373
|
+
where.push(eq(leaveRequests.personId, input.personId));
|
|
1374
|
+
else if (!context.principal.instanceAdmin) {
|
|
1375
|
+
// Without an explicit person, this is "my requests". Seeing everybody's by default
|
|
1376
|
+
// would leak the whole company's absences to any member with hr.leave.view.
|
|
1377
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1378
|
+
where.push(me ? eq(leaveRequests.personId, me.id) : sql `false`);
|
|
1379
|
+
}
|
|
1380
|
+
if (input.status?.length)
|
|
1381
|
+
where.push(inArray(leaveRequests.status, input.status));
|
|
1382
|
+
if (input.from)
|
|
1383
|
+
where.push(gte(leaveRequests.endsOn, input.from));
|
|
1384
|
+
if (input.to)
|
|
1385
|
+
where.push(lte(leaveRequests.startsOn, input.to));
|
|
1386
|
+
const rows = await tx
|
|
1387
|
+
.select()
|
|
1388
|
+
.from(leaveRequests)
|
|
1389
|
+
.where(and(...where))
|
|
1390
|
+
.orderBy(desc(leaveRequests.startsOn))
|
|
1391
|
+
.limit(input.limit);
|
|
1392
|
+
return { items: rows.map(toLeaveRequest), nextCursor: null };
|
|
1393
|
+
})),
|
|
1394
|
+
get: scoped.leave.requests.get
|
|
1395
|
+
.use(cap('leave'))
|
|
1396
|
+
.use(requires('hr.leave.view'))
|
|
1397
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => toLeaveRequest(await loadRequest(tx, input.workspaceId, input.requestId)))),
|
|
1398
|
+
simulate: scoped.leave.requests.simulate
|
|
1399
|
+
.use(cap('leave'))
|
|
1400
|
+
.use(requires('hr.leave.request'))
|
|
1401
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1402
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1403
|
+
return simulate(tx, input.workspaceId, personId, input);
|
|
1404
|
+
})),
|
|
1405
|
+
create: scoped.leave.requests.create
|
|
1406
|
+
.use(cap('leave'))
|
|
1407
|
+
.use(requires('hr.leave.request'))
|
|
1408
|
+
.handler(async ({ input, context }) => {
|
|
1409
|
+
const result = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1410
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1411
|
+
// Everything that spends balance takes the cursor lock first, inside this
|
|
1412
|
+
// transaction. Two overlapping requests for the last day cannot both read "enough".
|
|
1413
|
+
const year = yearOf(input.startsOn);
|
|
1414
|
+
await ledger.lockAndRead(tx, input.workspaceId, personId, input.leaveTypeId, year);
|
|
1415
|
+
const sim = await simulate(tx, input.workspaceId, personId, input);
|
|
1416
|
+
if (sim.blockers.length)
|
|
1417
|
+
throw KernError.conflict(sim.blockers[0].message, `hr.leave.${sim.blockers[0].code}`);
|
|
1418
|
+
const [request] = await tx
|
|
1419
|
+
.insert(leaveRequests)
|
|
1420
|
+
.values({
|
|
1421
|
+
id: uuidv7(),
|
|
1422
|
+
workspaceId: input.workspaceId,
|
|
1423
|
+
personId,
|
|
1424
|
+
leaveTypeId: input.leaveTypeId,
|
|
1425
|
+
startsOn: input.startsOn,
|
|
1426
|
+
endsOn: input.endsOn,
|
|
1427
|
+
startPart: input.startPart,
|
|
1428
|
+
endPart: input.endPart,
|
|
1429
|
+
hours: input.hours === null || input.hours === undefined ? null : String(input.hours),
|
|
1430
|
+
workingDays: String(sim.workingDays),
|
|
1431
|
+
minutes: sim.minutes,
|
|
1432
|
+
status: 'pending',
|
|
1433
|
+
reason: input.reason ?? null,
|
|
1434
|
+
documentFileId: input.documentFileId ?? null,
|
|
1435
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
1436
|
+
})
|
|
1437
|
+
.returning();
|
|
1438
|
+
// The exploded days are what the partial unique index guards, so this insert is what
|
|
1439
|
+
// actually refuses a double booking — before any approval happens.
|
|
1440
|
+
await tx.insert(leaveRequestDays).values(sim.days.map((d) => ({
|
|
1441
|
+
id: uuidv7(),
|
|
1442
|
+
workspaceId: input.workspaceId,
|
|
1443
|
+
requestId: request.id,
|
|
1444
|
+
personId,
|
|
1445
|
+
date: d.date,
|
|
1446
|
+
fraction: String(d.fraction),
|
|
1447
|
+
counted: d.counted,
|
|
1448
|
+
status: 'pending',
|
|
1449
|
+
})));
|
|
1450
|
+
const raised = await approvals.raise(tx, input.workspaceId, {
|
|
1451
|
+
subjectType: 'leave',
|
|
1452
|
+
subjectId: request.id,
|
|
1453
|
+
summary: `${sim.workingDays} day(s) from ${input.startsOn}`,
|
|
1454
|
+
requesterPersonId: personId,
|
|
1455
|
+
requestedBy: context.principal.userId ?? null,
|
|
1456
|
+
on: input.startsOn,
|
|
1457
|
+
});
|
|
1458
|
+
await tx
|
|
1459
|
+
.update(leaveRequests)
|
|
1460
|
+
.set({ approvalRequestId: raised.request.id })
|
|
1461
|
+
.where(eq(leaveRequests.id, request.id));
|
|
1462
|
+
// A chain that resolves to nobody approves immediately — a one-person company has no
|
|
1463
|
+
// manager and still has to be able to book time off.
|
|
1464
|
+
if (raised.autoApproved)
|
|
1465
|
+
await applyApproval(tx, input.workspaceId, request.id, context.principal.userId ?? null);
|
|
1466
|
+
const [fresh] = await tx.select().from(leaveRequests).where(eq(leaveRequests.id, request.id));
|
|
1467
|
+
return { request: fresh, approvers: raised.firstStepApprovers, personId };
|
|
1468
|
+
});
|
|
1469
|
+
await kernel.emit(hrEvents.leaveRequested, {
|
|
1470
|
+
requestId: result.request.id,
|
|
1471
|
+
workspaceId: input.workspaceId,
|
|
1472
|
+
personId: result.personId,
|
|
1473
|
+
startsOn: input.startsOn,
|
|
1474
|
+
endsOn: input.endsOn,
|
|
1475
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1476
|
+
await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
|
|
1477
|
+
return toLeaveRequest(result.request);
|
|
1478
|
+
}),
|
|
1479
|
+
cancel: scoped.leave.requests.cancel
|
|
1480
|
+
.use(cap('leave'))
|
|
1481
|
+
.use(requires('hr.leave.request'))
|
|
1482
|
+
.handler(async ({ input, context }) => {
|
|
1483
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1484
|
+
const request = await loadRequest(tx, input.workspaceId, input.requestId);
|
|
1485
|
+
if (request.status === 'cancelled' || request.status === 'withdrawn')
|
|
1486
|
+
throw KernError.conflict('That request is already cancelled');
|
|
1487
|
+
const year = yearOf(request.startsOn);
|
|
1488
|
+
await ledger.lockAndRead(tx, input.workspaceId, request.personId, request.leaveTypeId, year);
|
|
1489
|
+
// Approved leave is *reversed*, not deleted. "She booked it and cancelled" and "she
|
|
1490
|
+
// never booked it" are different facts, and only one of them is true.
|
|
1491
|
+
if (request.status === 'approved')
|
|
1492
|
+
for (const entry of await ledger.entriesFor(tx, input.workspaceId, request.id))
|
|
1493
|
+
if (entry.kind === 'consumption')
|
|
1494
|
+
await ledger.reverse(tx, input.workspaceId, entry.id, input.reason ?? 'Leave cancelled', context.principal.userId ?? null, todayIso());
|
|
1495
|
+
await approvals.cancel(tx, input.workspaceId, 'leave', request.id);
|
|
1496
|
+
const next = request.status === 'approved' ? 'withdrawn' : 'cancelled';
|
|
1497
|
+
await tx
|
|
1498
|
+
.update(leaveRequestDays)
|
|
1499
|
+
.set({ status: next })
|
|
1500
|
+
.where(eq(leaveRequestDays.requestId, request.id));
|
|
1501
|
+
const [updated] = await tx
|
|
1502
|
+
.update(leaveRequests)
|
|
1503
|
+
.set({ status: next, decidedAt: new Date(), updatedAt: new Date() })
|
|
1504
|
+
.where(eq(leaveRequests.id, request.id))
|
|
1505
|
+
.returning();
|
|
1506
|
+
return updated;
|
|
1507
|
+
});
|
|
1508
|
+
await kernel.emit(hrEvents.leaveDecided, {
|
|
1509
|
+
requestId: row.id,
|
|
1510
|
+
workspaceId: input.workspaceId,
|
|
1511
|
+
personId: row.personId,
|
|
1512
|
+
status: row.status,
|
|
1513
|
+
startsOn: row.startsOn,
|
|
1514
|
+
endsOn: row.endsOn,
|
|
1515
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1516
|
+
await changed(input.workspaceId, 'leave_request', row.id, 'updated');
|
|
1517
|
+
return toLeaveRequest(row);
|
|
1518
|
+
}),
|
|
1519
|
+
},
|
|
1520
|
+
team: {
|
|
1521
|
+
calendar: scoped.leave.team.calendar
|
|
1522
|
+
.use(cap('leave'))
|
|
1523
|
+
.use(requires('hr.leave.view_team'))
|
|
1524
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1525
|
+
const rows = await tx
|
|
1526
|
+
.select()
|
|
1527
|
+
.from(leaveRequests)
|
|
1528
|
+
.where(and(eq(leaveRequests.workspaceId, input.workspaceId), inArray(leaveRequests.status, ['pending', 'approved']), lte(leaveRequests.startsOn, input.to), gte(leaveRequests.endsOn, input.from)));
|
|
1529
|
+
if (!rows.length)
|
|
1530
|
+
return [];
|
|
1531
|
+
const personIds = [...new Set(rows.map((r) => r.personId))];
|
|
1532
|
+
const persons = await tx
|
|
1533
|
+
.select({ id: people.id, displayName: people.displayName })
|
|
1534
|
+
.from(people)
|
|
1535
|
+
.where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, personIds)));
|
|
1536
|
+
const nameById = new Map(persons.map((p) => [p.id, p.displayName]));
|
|
1537
|
+
const types = await tx
|
|
1538
|
+
.select()
|
|
1539
|
+
.from(leaveTypes)
|
|
1540
|
+
.where(eq(leaveTypes.workspaceId, input.workspaceId));
|
|
1541
|
+
const typeById = new Map(types.map((t) => [t.id, t]));
|
|
1542
|
+
// Most companies want the team to know somebody is away without knowing it is sick
|
|
1543
|
+
// leave, so the type is named only for somebody who may read the ledger.
|
|
1544
|
+
const maySeeType = await kernel.authz.can(context.principal, 'hr.leave.view_ledger', {
|
|
1545
|
+
kind: 'workspace',
|
|
1546
|
+
id: input.workspaceId,
|
|
1547
|
+
workspaceId: input.workspaceId,
|
|
1548
|
+
});
|
|
1549
|
+
let filtered = rows;
|
|
1550
|
+
if (input.officeId) {
|
|
1551
|
+
const here = await tx
|
|
1552
|
+
.select({ personId: officeAssignments.personId })
|
|
1553
|
+
.from(officeAssignments)
|
|
1554
|
+
.where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
|
|
1555
|
+
const ids = new Set(here.map((h) => h.personId));
|
|
1556
|
+
filtered = filtered.filter((r) => ids.has(r.personId));
|
|
1557
|
+
}
|
|
1558
|
+
if (input.orgUnitId) {
|
|
1559
|
+
const ids = new Set(await unitMemberIds(tx, input.workspaceId, input.orgUnitId, true));
|
|
1560
|
+
filtered = filtered.filter((r) => ids.has(r.personId));
|
|
1561
|
+
}
|
|
1562
|
+
return filtered.map((r) => {
|
|
1563
|
+
const type = typeById.get(r.leaveTypeId);
|
|
1564
|
+
return {
|
|
1565
|
+
personId: r.personId,
|
|
1566
|
+
displayName: nameById.get(r.personId) ?? 'Unknown',
|
|
1567
|
+
requestId: r.id,
|
|
1568
|
+
startsOn: r.startsOn,
|
|
1569
|
+
endsOn: r.endsOn,
|
|
1570
|
+
status: r.status,
|
|
1571
|
+
leaveTypeName: maySeeType ? (type?.name ?? null) : null,
|
|
1572
|
+
color: type?.color ?? null,
|
|
1573
|
+
};
|
|
1574
|
+
});
|
|
1575
|
+
})),
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
// ================================================================= approvals
|
|
1579
|
+
approvals: {
|
|
1580
|
+
/**
|
|
1581
|
+
* Everything waiting on the caller. No permission: an inbox of what *you* must decide is
|
|
1582
|
+
* yours by definition, and the engine only lists steps you are named on.
|
|
1583
|
+
*/
|
|
1584
|
+
inbox: scoped.approvals.inbox.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1585
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1586
|
+
if (!me)
|
|
1587
|
+
return { items: [], nextCursor: null };
|
|
1588
|
+
const rows = await approvals.inboxFor(tx, input.workspaceId, me.id, input.includeDecided, input.limit);
|
|
1589
|
+
const items = [];
|
|
1590
|
+
for (const r of rows)
|
|
1591
|
+
items.push(await hydrateApproval(tx, r));
|
|
1592
|
+
return { items, nextCursor: null };
|
|
1593
|
+
})),
|
|
1594
|
+
get: scoped.approvals.get.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1595
|
+
const [row] = await tx
|
|
1596
|
+
.select()
|
|
1597
|
+
.from(approvalRequests)
|
|
1598
|
+
.where(and(eq(approvalRequests.workspaceId, input.workspaceId), eq(approvalRequests.id, input.requestId)))
|
|
1599
|
+
.limit(1);
|
|
1600
|
+
if (!row)
|
|
1601
|
+
throw KernError.notFound('Approval request');
|
|
1602
|
+
return hydrateApproval(tx, row);
|
|
1603
|
+
})),
|
|
1604
|
+
decide: scoped.approvals.decide.handler(async ({ input, context }) => {
|
|
1605
|
+
const outcome = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1606
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1607
|
+
if (!me)
|
|
1608
|
+
throw KernError.forbidden('You have no employee record in this workspace');
|
|
1609
|
+
const result = await approvals.decide(tx, input.workspaceId, input.requestId, me.id, input.decision, input.comment ?? null, input.onBehalfOfId ?? null);
|
|
1610
|
+
// The approval engine knows nothing about leave. Applying the decision to the subject is
|
|
1611
|
+
// the caller's job, which is what keeps the engine reusable for regularization and
|
|
1612
|
+
// overtime later.
|
|
1613
|
+
const request = result.request;
|
|
1614
|
+
if (request.subjectType === 'leave') {
|
|
1615
|
+
if (result.status === 'approved')
|
|
1616
|
+
await applyApproval(tx, input.workspaceId, request.subjectId, context.principal.userId ?? null);
|
|
1617
|
+
else if (result.status === 'rejected')
|
|
1618
|
+
await tx
|
|
1619
|
+
.update(leaveRequests)
|
|
1620
|
+
.set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
|
|
1621
|
+
.where(eq(leaveRequests.id, request.subjectId));
|
|
1622
|
+
}
|
|
1623
|
+
const [fresh] = await tx
|
|
1624
|
+
.select()
|
|
1625
|
+
.from(approvalRequests)
|
|
1626
|
+
.where(eq(approvalRequests.id, input.requestId));
|
|
1627
|
+
return { hydrated: await hydrateApproval(tx, fresh), request: fresh };
|
|
1628
|
+
});
|
|
1629
|
+
await kernel.emit(hrEvents.approvalDecided, {
|
|
1630
|
+
requestId: outcome.request.id,
|
|
1631
|
+
workspaceId: input.workspaceId,
|
|
1632
|
+
subjectType: outcome.request.subjectType,
|
|
1633
|
+
subjectId: outcome.request.subjectId,
|
|
1634
|
+
status: outcome.request.status,
|
|
1635
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1636
|
+
await changed(input.workspaceId, 'approval', outcome.request.id, 'updated');
|
|
1637
|
+
return outcome.hydrated;
|
|
1638
|
+
}),
|
|
1639
|
+
chains: {
|
|
1640
|
+
list: scoped.approvals.chains.list
|
|
1641
|
+
.use(cap('approvals'))
|
|
1642
|
+
.use(requires('hr.approval.manage'))
|
|
1643
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1644
|
+
const where = [
|
|
1645
|
+
eq(approvalChains.workspaceId, input.workspaceId),
|
|
1646
|
+
isNull(approvalChains.archivedAt),
|
|
1647
|
+
];
|
|
1648
|
+
if (input.subjectType)
|
|
1649
|
+
where.push(eq(approvalChains.subjectType, input.subjectType));
|
|
1650
|
+
const rows = await tx
|
|
1651
|
+
.select()
|
|
1652
|
+
.from(approvalChains)
|
|
1653
|
+
.where(and(...where))
|
|
1654
|
+
.orderBy(asc(approvalChains.name));
|
|
1655
|
+
return rows.map(toChain);
|
|
1656
|
+
})),
|
|
1657
|
+
create: scoped.approvals.chains.create
|
|
1658
|
+
.use(cap('approvals'))
|
|
1659
|
+
.use(requires('hr.approval.manage'))
|
|
1660
|
+
.handler(async ({ input }) => {
|
|
1661
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1662
|
+
if (input.isDefault)
|
|
1663
|
+
await clearDefaultChain(tx, input.workspaceId, input.subjectType);
|
|
1664
|
+
const [created] = await tx
|
|
1665
|
+
.insert(approvalChains)
|
|
1666
|
+
.values({
|
|
1667
|
+
id: uuidv7(),
|
|
1668
|
+
workspaceId: input.workspaceId,
|
|
1669
|
+
name: input.name,
|
|
1670
|
+
subjectType: input.subjectType,
|
|
1671
|
+
spec: input.spec,
|
|
1672
|
+
isDefault: input.isDefault,
|
|
1673
|
+
})
|
|
1674
|
+
.returning();
|
|
1675
|
+
return created;
|
|
1676
|
+
});
|
|
1677
|
+
await changed(input.workspaceId, 'approval_chain', row.id, 'created');
|
|
1678
|
+
return toChain(row);
|
|
1679
|
+
}),
|
|
1680
|
+
update: scoped.approvals.chains.update
|
|
1681
|
+
.use(cap('approvals'))
|
|
1682
|
+
.use(requires('hr.approval.manage'))
|
|
1683
|
+
.handler(async ({ input }) => {
|
|
1684
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1685
|
+
const [existing] = await tx
|
|
1686
|
+
.select()
|
|
1687
|
+
.from(approvalChains)
|
|
1688
|
+
.where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId)))
|
|
1689
|
+
.limit(1);
|
|
1690
|
+
if (!existing)
|
|
1691
|
+
throw KernError.notFound('Approval chain');
|
|
1692
|
+
if (input.isDefault)
|
|
1693
|
+
await clearDefaultChain(tx, input.workspaceId, existing.subjectType);
|
|
1694
|
+
const set = { updatedAt: new Date() };
|
|
1695
|
+
if (input.name !== undefined)
|
|
1696
|
+
set.name = input.name;
|
|
1697
|
+
if (input.spec !== undefined)
|
|
1698
|
+
set.spec = input.spec;
|
|
1699
|
+
if (input.isDefault !== undefined)
|
|
1700
|
+
set.isDefault = input.isDefault;
|
|
1701
|
+
const [updated] = await tx
|
|
1702
|
+
.update(approvalChains)
|
|
1703
|
+
.set(set)
|
|
1704
|
+
.where(eq(approvalChains.id, input.chainId))
|
|
1705
|
+
.returning();
|
|
1706
|
+
return updated;
|
|
1707
|
+
});
|
|
1708
|
+
await changed(input.workspaceId, 'approval_chain', row.id, 'updated');
|
|
1709
|
+
return toChain(row);
|
|
1710
|
+
}),
|
|
1711
|
+
archive: scoped.approvals.chains.archive
|
|
1712
|
+
.use(cap('approvals'))
|
|
1713
|
+
.use(requires('hr.approval.manage'))
|
|
1714
|
+
.handler(async ({ input }) => {
|
|
1715
|
+
// In-flight requests carry their own snapshot of the chain, so archiving one cannot
|
|
1716
|
+
// strand an approval half-signed.
|
|
1717
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
1718
|
+
.update(approvalChains)
|
|
1719
|
+
.set({ archivedAt: new Date(), isDefault: false })
|
|
1720
|
+
.where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId))));
|
|
1721
|
+
await changed(input.workspaceId, 'approval_chain', input.chainId, 'deleted');
|
|
1722
|
+
return { ok: true };
|
|
1723
|
+
}),
|
|
1724
|
+
},
|
|
1725
|
+
delegations: scoped.approvals.delegations
|
|
1726
|
+
.use(cap('approvals'))
|
|
1727
|
+
.use(requires('hr.approval.delegate'))
|
|
1728
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1729
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1730
|
+
const rows = await tx
|
|
1731
|
+
.select()
|
|
1732
|
+
.from(delegations)
|
|
1733
|
+
.where(and(eq(delegations.workspaceId, input.workspaceId), or(eq(delegations.fromPersonId, personId), eq(delegations.toPersonId, personId))))
|
|
1734
|
+
.orderBy(desc(delegations.startsOn));
|
|
1735
|
+
return rows.map(toDelegation);
|
|
1736
|
+
})),
|
|
1737
|
+
delegate: scoped.approvals.delegate
|
|
1738
|
+
.use(cap('approvals'))
|
|
1739
|
+
.use(requires('hr.approval.delegate'))
|
|
1740
|
+
.handler(async ({ input, context }) => {
|
|
1741
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1742
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1743
|
+
if (!me)
|
|
1744
|
+
throw KernError.forbidden('You have no employee record in this workspace');
|
|
1745
|
+
if (me.id === input.toPersonId)
|
|
1746
|
+
throw KernError.badRequest('You cannot delegate your approvals to yourself.');
|
|
1747
|
+
if (input.endsOn < input.startsOn)
|
|
1748
|
+
throw KernError.badRequest('A delegation cannot end before it starts.');
|
|
1749
|
+
const [created] = await tx
|
|
1750
|
+
.insert(delegations)
|
|
1751
|
+
.values({
|
|
1752
|
+
id: uuidv7(),
|
|
1753
|
+
workspaceId: input.workspaceId,
|
|
1754
|
+
fromPersonId: me.id,
|
|
1755
|
+
toPersonId: input.toPersonId,
|
|
1756
|
+
subjectType: input.subjectType ?? null,
|
|
1757
|
+
startsOn: input.startsOn,
|
|
1758
|
+
endsOn: input.endsOn,
|
|
1759
|
+
reason: input.reason ?? null,
|
|
1760
|
+
})
|
|
1761
|
+
.returning();
|
|
1762
|
+
return created;
|
|
1763
|
+
});
|
|
1764
|
+
await changed(input.workspaceId, 'delegation', row.id, 'created');
|
|
1765
|
+
return toDelegation(row);
|
|
1766
|
+
}),
|
|
1767
|
+
revokeDelegation: scoped.approvals.revokeDelegation
|
|
1768
|
+
.use(cap('approvals'))
|
|
1769
|
+
.use(requires('hr.approval.delegate'))
|
|
1770
|
+
.handler(async ({ input, context }) => {
|
|
1771
|
+
await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1772
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1773
|
+
const [row] = await tx
|
|
1774
|
+
.select()
|
|
1775
|
+
.from(delegations)
|
|
1776
|
+
.where(and(eq(delegations.workspaceId, input.workspaceId), eq(delegations.id, input.delegationId)))
|
|
1777
|
+
.limit(1);
|
|
1778
|
+
if (!row)
|
|
1779
|
+
throw KernError.notFound('Delegation');
|
|
1780
|
+
// Only the person who gave it away may take it back — otherwise a delegate could quietly
|
|
1781
|
+
// extend their own authority by revoking the competition.
|
|
1782
|
+
if (row.fromPersonId !== me?.id)
|
|
1783
|
+
throw KernError.forbidden('Only the person who delegated may revoke it');
|
|
1784
|
+
await tx.delete(delegations).where(eq(delegations.id, row.id));
|
|
1785
|
+
});
|
|
1786
|
+
await changed(input.workspaceId, 'delegation', input.delegationId, 'deleted');
|
|
1787
|
+
return { ok: true };
|
|
1788
|
+
}),
|
|
1789
|
+
},
|
|
1223
1790
|
// ================================================================= custom fields
|
|
1224
1791
|
fields: {
|
|
1225
1792
|
list: scoped.fields.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
@@ -1495,6 +2062,217 @@ export function implement_(kernel) {
|
|
|
1495
2062
|
const parent = await parentPath(tx, workspaceId, parentId);
|
|
1496
2063
|
return parent ? `${parent}.${label}` : label;
|
|
1497
2064
|
}
|
|
2065
|
+
/**
|
|
2066
|
+
* The person a call is about: the one named, or the caller.
|
|
2067
|
+
*
|
|
2068
|
+
* Reading somebody else's balance needs `hr.leave.view_team`; reading your own needs nothing
|
|
2069
|
+
* beyond being an employee. Collapsing those into one permission would either hide your own
|
|
2070
|
+
* balance from you or show you everybody's.
|
|
2071
|
+
*/
|
|
2072
|
+
async function personFor(tx, workspaceId, context, personId) {
|
|
2073
|
+
const me = await svc.byUserId(tx, workspaceId, context.principal.userId ?? '');
|
|
2074
|
+
if (!personId) {
|
|
2075
|
+
if (!me)
|
|
2076
|
+
throw KernError.notFound('Your employee record');
|
|
2077
|
+
return me.id;
|
|
2078
|
+
}
|
|
2079
|
+
if (me && me.id === personId)
|
|
2080
|
+
return personId;
|
|
2081
|
+
await kernel.authz.require(context.principal, 'hr.leave.view_team', {
|
|
2082
|
+
kind: 'workspace',
|
|
2083
|
+
id: workspaceId,
|
|
2084
|
+
workspaceId,
|
|
2085
|
+
});
|
|
2086
|
+
return personId;
|
|
2087
|
+
}
|
|
2088
|
+
async function loadRequest(tx, workspaceId, requestId) {
|
|
2089
|
+
const [row] = await tx
|
|
2090
|
+
.select()
|
|
2091
|
+
.from(leaveRequests)
|
|
2092
|
+
.where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
|
|
2093
|
+
.limit(1);
|
|
2094
|
+
if (!row)
|
|
2095
|
+
throw KernError.notFound('Leave request');
|
|
2096
|
+
return row;
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* What a request would cost, and every reason it would be refused.
|
|
2100
|
+
*
|
|
2101
|
+
* Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
|
|
2102
|
+
* submission is a preview that eventually lies. The blockers are returned rather than thrown here
|
|
2103
|
+
* so the screen can show all of them at once instead of one per round trip.
|
|
2104
|
+
*/
|
|
2105
|
+
async function simulate(tx, workspaceId, personId, input) {
|
|
2106
|
+
const blockers = [];
|
|
2107
|
+
if (input.endsOn < input.startsOn)
|
|
2108
|
+
blockers.push({ code: 'range', message: 'The end date is before the start date.' });
|
|
2109
|
+
const [type] = await tx
|
|
2110
|
+
.select()
|
|
2111
|
+
.from(leaveTypes)
|
|
2112
|
+
.where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
|
|
2113
|
+
.limit(1);
|
|
2114
|
+
if (!type)
|
|
2115
|
+
throw KernError.notFound('Leave type');
|
|
2116
|
+
if (type.archivedAt)
|
|
2117
|
+
blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
|
|
2118
|
+
const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
|
|
2119
|
+
const calendarDaysInRange = resolution.calendarId
|
|
2120
|
+
? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
|
|
2121
|
+
: [];
|
|
2122
|
+
const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
|
|
2123
|
+
? calendarDaysInRange.map((d) => ({
|
|
2124
|
+
date: d.date,
|
|
2125
|
+
name: d.name,
|
|
2126
|
+
workingFraction: d.workingFraction,
|
|
2127
|
+
}))
|
|
2128
|
+
: []);
|
|
2129
|
+
// Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
|
|
2130
|
+
// holiday still costs nothing rather than costing half of nothing.
|
|
2131
|
+
const days = results.map((r) => {
|
|
2132
|
+
let fraction = r.fraction;
|
|
2133
|
+
if (r.date === input.startsOn && input.startPart === 'afternoon')
|
|
2134
|
+
fraction = Math.min(fraction, 0.5);
|
|
2135
|
+
if (r.date === input.endsOn && input.endPart === 'morning')
|
|
2136
|
+
fraction = Math.min(fraction, 0.5);
|
|
2137
|
+
return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
|
|
2138
|
+
});
|
|
2139
|
+
const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
|
|
2140
|
+
const minutes = type.unit === 'hour' && input.hours
|
|
2141
|
+
? Math.round(input.hours * 60)
|
|
2142
|
+
: Math.round(workingDaysTotal * MINUTES_PER_DAY);
|
|
2143
|
+
if (minutes <= 0)
|
|
2144
|
+
blockers.push({
|
|
2145
|
+
code: 'empty',
|
|
2146
|
+
message: 'That range contains no working days.',
|
|
2147
|
+
});
|
|
2148
|
+
const year = yearOf(input.startsOn);
|
|
2149
|
+
const balances = await ledger.balances(tx, workspaceId, personId, year);
|
|
2150
|
+
const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
|
|
2151
|
+
const before = balance?.availableMinutes ?? 0;
|
|
2152
|
+
const after = before - minutes;
|
|
2153
|
+
if (after < 0 && !type.allowNegative)
|
|
2154
|
+
blockers.push({
|
|
2155
|
+
code: 'insufficient',
|
|
2156
|
+
message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
|
|
2157
|
+
});
|
|
2158
|
+
if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
|
|
2159
|
+
blockers.push({
|
|
2160
|
+
code: 'below_floor',
|
|
2161
|
+
message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
|
|
2162
|
+
});
|
|
2163
|
+
// Overlap is refused by a unique index as well; checking here turns a constraint violation into
|
|
2164
|
+
// a sentence naming the dates.
|
|
2165
|
+
const counted = days.filter((d) => d.counted).map((d) => d.date);
|
|
2166
|
+
if (counted.length) {
|
|
2167
|
+
const clash = await tx
|
|
2168
|
+
.select({ date: leaveRequestDays.date })
|
|
2169
|
+
.from(leaveRequestDays)
|
|
2170
|
+
.where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
|
|
2171
|
+
.limit(3);
|
|
2172
|
+
if (clash.length)
|
|
2173
|
+
blockers.push({
|
|
2174
|
+
code: 'overlap',
|
|
2175
|
+
message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
|
|
2179
|
+
blockers.push({
|
|
2180
|
+
code: 'document_required',
|
|
2181
|
+
message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
|
|
2182
|
+
});
|
|
2183
|
+
return {
|
|
2184
|
+
workingDays: workingDaysTotal,
|
|
2185
|
+
minutes,
|
|
2186
|
+
days,
|
|
2187
|
+
balanceBeforeMinutes: before,
|
|
2188
|
+
balanceAfterMinutes: after,
|
|
2189
|
+
blockers,
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Turn an approved request into a ledger consumption.
|
|
2194
|
+
*
|
|
2195
|
+
* The working days are **recomputed here** rather than trusted from submission time: a holiday
|
|
2196
|
+
* can be added to the calendar between asking and approving, and the number that costs somebody
|
|
2197
|
+
* balance should be the one that was true when it was granted.
|
|
2198
|
+
*/
|
|
2199
|
+
async function applyApproval(tx, workspaceId, leaveRequestId, actorId) {
|
|
2200
|
+
const request = await loadRequest(tx, workspaceId, leaveRequestId);
|
|
2201
|
+
if (request.status === 'approved')
|
|
2202
|
+
return;
|
|
2203
|
+
const sim = await simulate(tx, workspaceId, request.personId, {
|
|
2204
|
+
leaveTypeId: request.leaveTypeId,
|
|
2205
|
+
startsOn: request.startsOn,
|
|
2206
|
+
endsOn: request.endsOn,
|
|
2207
|
+
startPart: request.startPart,
|
|
2208
|
+
endPart: request.endPart,
|
|
2209
|
+
hours: request.hours === null ? null : Number.parseFloat(request.hours),
|
|
2210
|
+
});
|
|
2211
|
+
await ledger.append(tx, workspaceId, {
|
|
2212
|
+
personId: request.personId,
|
|
2213
|
+
leaveTypeId: request.leaveTypeId,
|
|
2214
|
+
kind: 'consumption',
|
|
2215
|
+
amountMinutes: -sim.minutes,
|
|
2216
|
+
effectiveOn: request.startsOn,
|
|
2217
|
+
periodYear: yearOf(request.startsOn),
|
|
2218
|
+
requestId: request.id,
|
|
2219
|
+
reason: null,
|
|
2220
|
+
createdBy: actorId,
|
|
2221
|
+
});
|
|
2222
|
+
await tx
|
|
2223
|
+
.update(leaveRequestDays)
|
|
2224
|
+
.set({ status: 'approved' })
|
|
2225
|
+
.where(eq(leaveRequestDays.requestId, request.id));
|
|
2226
|
+
await tx
|
|
2227
|
+
.update(leaveRequests)
|
|
2228
|
+
.set({
|
|
2229
|
+
status: 'approved',
|
|
2230
|
+
minutes: sim.minutes,
|
|
2231
|
+
workingDays: String(sim.workingDays),
|
|
2232
|
+
decidedAt: new Date(),
|
|
2233
|
+
updatedAt: new Date(),
|
|
2234
|
+
})
|
|
2235
|
+
.where(eq(leaveRequests.id, request.id));
|
|
2236
|
+
}
|
|
2237
|
+
async function clearDefaultChain(tx, workspaceId, subjectType) {
|
|
2238
|
+
await tx
|
|
2239
|
+
.update(approvalChains)
|
|
2240
|
+
.set({ isDefault: false })
|
|
2241
|
+
.where(and(eq(approvalChains.workspaceId, workspaceId), eq(approvalChains.subjectType, subjectType), eq(approvalChains.isDefault, true)));
|
|
2242
|
+
}
|
|
2243
|
+
/** An approval request with its steps and decisions, which is the only useful shape. */
|
|
2244
|
+
async function hydrateApproval(tx, row) {
|
|
2245
|
+
const steps = await tx
|
|
2246
|
+
.select()
|
|
2247
|
+
.from(approvalSteps)
|
|
2248
|
+
.where(eq(approvalSteps.requestId, row.id))
|
|
2249
|
+
.orderBy(asc(approvalSteps.stepIndex));
|
|
2250
|
+
const stepIds = steps.map((s) => s.id);
|
|
2251
|
+
const decisions = stepIds.length
|
|
2252
|
+
? await tx.select().from(approvalDecisions).where(inArray(approvalDecisions.stepId, stepIds))
|
|
2253
|
+
: [];
|
|
2254
|
+
return {
|
|
2255
|
+
...row,
|
|
2256
|
+
subjectType: row.subjectType,
|
|
2257
|
+
status: row.status,
|
|
2258
|
+
requestedAt: row.requestedAt.toISOString(),
|
|
2259
|
+
decidedAt: row.decidedAt?.toISOString() ?? null,
|
|
2260
|
+
steps: steps.map((s) => ({
|
|
2261
|
+
...s,
|
|
2262
|
+
mode: s.mode,
|
|
2263
|
+
status: s.status,
|
|
2264
|
+
dueAt: s.dueAt?.toISOString() ?? null,
|
|
2265
|
+
escalatedAt: s.escalatedAt?.toISOString() ?? null,
|
|
2266
|
+
decisions: decisions
|
|
2267
|
+
.filter((d) => d.stepId === s.id)
|
|
2268
|
+
.map((d) => ({
|
|
2269
|
+
...d,
|
|
2270
|
+
decision: d.decision,
|
|
2271
|
+
at: d.at.toISOString(),
|
|
2272
|
+
})),
|
|
2273
|
+
})),
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
1498
2276
|
async function emitCalendarChanged(workspaceId, calendarId, from, to, actorId) {
|
|
1499
2277
|
await kernel.emit(hrEvents.calendarChanged, { calendarId, workspaceId, from, to }, { workspaceId, actorId });
|
|
1500
2278
|
await changed(workspaceId, 'calendar', calendarId, 'updated');
|
|
@@ -1534,4 +2312,36 @@ const toResolvedDay = (r, fromCalendarId, fromCalendarName, overrides) => ({
|
|
|
1534
2312
|
fromCalendarName,
|
|
1535
2313
|
overrides,
|
|
1536
2314
|
});
|
|
2315
|
+
const toLeaveType = (r) => ({
|
|
2316
|
+
...r,
|
|
2317
|
+
unit: r.unit,
|
|
2318
|
+
archivedAt: r.archivedAt?.toISOString() ?? null,
|
|
2319
|
+
});
|
|
2320
|
+
const toLedgerEntry = (r) => ({
|
|
2321
|
+
...r,
|
|
2322
|
+
kind: r.kind,
|
|
2323
|
+
createdAt: r.createdAt.toISOString(),
|
|
2324
|
+
});
|
|
2325
|
+
const toLeaveRequest = (r) => ({
|
|
2326
|
+
...r,
|
|
2327
|
+
startPart: r.startPart,
|
|
2328
|
+
endPart: r.endPart,
|
|
2329
|
+
status: r.status,
|
|
2330
|
+
hours: r.hours === null ? null : Number.parseFloat(r.hours),
|
|
2331
|
+
workingDays: Number.parseFloat(r.workingDays),
|
|
2332
|
+
decidedAt: r.decidedAt?.toISOString() ?? null,
|
|
2333
|
+
createdAt: r.createdAt.toISOString(),
|
|
2334
|
+
updatedAt: r.updatedAt.toISOString(),
|
|
2335
|
+
});
|
|
2336
|
+
const toChain = (r) => ({
|
|
2337
|
+
...r,
|
|
2338
|
+
subjectType: r.subjectType,
|
|
2339
|
+
spec: r.spec,
|
|
2340
|
+
archivedAt: r.archivedAt?.toISOString() ?? null,
|
|
2341
|
+
});
|
|
2342
|
+
const toDelegation = (r) => ({
|
|
2343
|
+
...r,
|
|
2344
|
+
subjectType: (r.subjectType ?? null),
|
|
2345
|
+
createdAt: r.createdAt.toISOString(),
|
|
2346
|
+
});
|
|
1537
2347
|
//# sourceMappingURL=router.js.map
|