@kernhq/module-hr 0.2.0 → 0.4.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/attendance.d.ts +240 -0
- package/dist/contract/attendance.d.ts.map +1 -0
- package/dist/contract/attendance.js +154 -0
- package/dist/contract/attendance.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 +80 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +60 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +60 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +3 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +3 -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 +100 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +119 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +2440 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +393 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/policy/time.d.ts +48 -0
- package/dist/policy/time.d.ts.map +1 -0
- package/dist/policy/time.js +144 -0
- package/dist/policy/time.js.map +1 -0
- package/dist/policy/working-time.d.ts +98 -0
- package/dist/policy/working-time.d.ts.map +1 -0
- package/dist/policy/working-time.js +190 -0
- package/dist/policy/working-time.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +16 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +166 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +2731 -58
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +1306 -3
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +3344 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +325 -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/attendance.d.ts +150 -0
- package/dist/server/services/attendance.d.ts.map +1 -0
- package/dist/server/services/attendance.js +239 -0
- package/dist/server/services/attendance.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/0003_attendance.sql +206 -0
- package/migrations/meta/0002_snapshot.json +3009 -0
- package/migrations/meta/0003_snapshot.json +3681 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/contract/approvals.ts +149 -0
- package/src/contract/attendance.ts +180 -0
- package/src/contract/capabilities.ts +80 -0
- package/src/contract/events.ts +81 -0
- package/src/contract/index.ts +3 -0
- package/src/contract/leave.ts +171 -0
- package/src/contract/permissions.ts +122 -0
- package/src/contract/router.ts +481 -0
package/dist/server/router.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
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
|
+
import { businessDateFor } from '../policy/working-time.js';
|
|
6
7
|
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';
|
|
8
|
-
import {
|
|
8
|
+
import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, attendanceDays, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, personDocuments, personHistory, positions, punches, regularizations, scheduleAssignments, schedules, } from './schema.js';
|
|
9
|
+
import { ApprovalService } from './services/approvals.js';
|
|
10
|
+
import { AttendanceService } from './services/attendance.js';
|
|
11
|
+
import { inForceOn, todayIn, todayIso } from './services/db.js';
|
|
12
|
+
import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
|
|
9
13
|
import { PeopleService } from './services/people.js';
|
|
10
14
|
import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
|
|
11
15
|
const os = implement(hrContract).$context();
|
|
@@ -26,6 +30,9 @@ export function implement_(kernel) {
|
|
|
26
30
|
const cap = (id) => requiresCapability(MODULE_ID, id);
|
|
27
31
|
const resolve = new ResolveService();
|
|
28
32
|
const svc = new PeopleService(kernel);
|
|
33
|
+
const ledger = new LedgerService();
|
|
34
|
+
const approvals = new ApprovalService(kernel);
|
|
35
|
+
const attendance = new AttendanceService();
|
|
29
36
|
const db = kernel.database;
|
|
30
37
|
const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
|
|
31
38
|
const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
|
|
@@ -1220,6 +1227,906 @@ export function implement_(kernel) {
|
|
|
1220
1227
|
return { ok: true };
|
|
1221
1228
|
}),
|
|
1222
1229
|
},
|
|
1230
|
+
// ================================================================= attendance
|
|
1231
|
+
attendance: {
|
|
1232
|
+
state: scoped.attendance.state
|
|
1233
|
+
.use(cap('attendance'))
|
|
1234
|
+
.use(requires('hr.attendance.view'))
|
|
1235
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1236
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1237
|
+
const { timezone, businessDate, schedule } = await clockContext(tx, input.workspaceId, personId);
|
|
1238
|
+
const rows = await attendance.punchesOn(tx, input.workspaceId, personId, businessDate);
|
|
1239
|
+
let open = null;
|
|
1240
|
+
let onBreak = false;
|
|
1241
|
+
let workedMs = 0;
|
|
1242
|
+
let breakOpen = null;
|
|
1243
|
+
for (const r of rows) {
|
|
1244
|
+
if (r.direction === 'in')
|
|
1245
|
+
open = r.at;
|
|
1246
|
+
else if (r.direction === 'out' && open) {
|
|
1247
|
+
workedMs += r.at.getTime() - open.getTime();
|
|
1248
|
+
open = null;
|
|
1249
|
+
}
|
|
1250
|
+
else if (r.direction === 'break_start') {
|
|
1251
|
+
breakOpen = r.at;
|
|
1252
|
+
onBreak = true;
|
|
1253
|
+
}
|
|
1254
|
+
else if (r.direction === 'break_end' && breakOpen) {
|
|
1255
|
+
workedMs -= r.at.getTime() - breakOpen.getTime();
|
|
1256
|
+
breakOpen = null;
|
|
1257
|
+
onBreak = false;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
// An open span counts up to now, so the widget shows time accruing rather than freezing
|
|
1261
|
+
// at the last completed pair.
|
|
1262
|
+
if (open)
|
|
1263
|
+
workedMs += Date.now() - open.getTime();
|
|
1264
|
+
if (breakOpen)
|
|
1265
|
+
workedMs -= Date.now() - breakOpen.getTime();
|
|
1266
|
+
void schedule;
|
|
1267
|
+
return {
|
|
1268
|
+
personId,
|
|
1269
|
+
businessDate,
|
|
1270
|
+
clockedIn: open !== null,
|
|
1271
|
+
onBreak,
|
|
1272
|
+
since: (open ?? breakOpen)?.toISOString() ?? null,
|
|
1273
|
+
workedMinutesToday: Math.max(0, Math.round(workedMs / 60000)),
|
|
1274
|
+
timezone,
|
|
1275
|
+
};
|
|
1276
|
+
})),
|
|
1277
|
+
clockIn: scoped.attendance.clockIn
|
|
1278
|
+
.use(cap('attendance'))
|
|
1279
|
+
.use(requires('hr.attendance.punch'))
|
|
1280
|
+
.handler(({ input, context }) => punch(input, context, 'in')),
|
|
1281
|
+
clockOut: scoped.attendance.clockOut
|
|
1282
|
+
.use(cap('attendance'))
|
|
1283
|
+
.use(requires('hr.attendance.punch'))
|
|
1284
|
+
.handler(({ input, context }) => punch(input, context, 'out')),
|
|
1285
|
+
breakStart: scoped.attendance.breakStart
|
|
1286
|
+
.use(cap('attendance'))
|
|
1287
|
+
.use(requires('hr.attendance.punch'))
|
|
1288
|
+
.handler(({ input, context }) => punch(input, context, 'break_start')),
|
|
1289
|
+
breakEnd: scoped.attendance.breakEnd
|
|
1290
|
+
.use(cap('attendance'))
|
|
1291
|
+
.use(requires('hr.attendance.punch'))
|
|
1292
|
+
.handler(({ input, context }) => punch(input, context, 'break_end')),
|
|
1293
|
+
punches: {
|
|
1294
|
+
list: scoped.attendance.punches.list
|
|
1295
|
+
.use(cap('attendance'))
|
|
1296
|
+
.use(requires('hr.attendance.view'))
|
|
1297
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1298
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1299
|
+
const where = [
|
|
1300
|
+
eq(punches.workspaceId, input.workspaceId),
|
|
1301
|
+
eq(punches.personId, personId),
|
|
1302
|
+
gte(punches.businessDate, input.from),
|
|
1303
|
+
lte(punches.businessDate, input.to),
|
|
1304
|
+
];
|
|
1305
|
+
if (!input.includeVoided)
|
|
1306
|
+
where.push(isNull(punches.voidedByPunchId));
|
|
1307
|
+
const rows = await tx
|
|
1308
|
+
.select()
|
|
1309
|
+
.from(punches)
|
|
1310
|
+
.where(and(...where))
|
|
1311
|
+
.orderBy(asc(punches.at))
|
|
1312
|
+
.limit(input.limit);
|
|
1313
|
+
return { items: rows.map(toPunch), nextCursor: null };
|
|
1314
|
+
})),
|
|
1315
|
+
void: scoped.attendance.punches.void
|
|
1316
|
+
.use(cap('attendance'))
|
|
1317
|
+
.use(requires('hr.attendance.manage'))
|
|
1318
|
+
.handler(async ({ input, context }) => {
|
|
1319
|
+
const affected = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1320
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1321
|
+
const { original } = await attendance.voidPunch(tx, input.workspaceId, input.punchId, input.reason, me?.id ?? null);
|
|
1322
|
+
// The day is derived, so voiding a punch means the sheet is stale until it is rebuilt.
|
|
1323
|
+
const { timezone, schedule } = await clockContext(tx, input.workspaceId, original.personId);
|
|
1324
|
+
await attendance.recomputeDay(tx, input.workspaceId, original.personId, original.businessDate, timezone, schedule);
|
|
1325
|
+
return original;
|
|
1326
|
+
});
|
|
1327
|
+
await changed(input.workspaceId, 'attendance_day', affected.personId, 'updated');
|
|
1328
|
+
return { ok: true };
|
|
1329
|
+
}),
|
|
1330
|
+
},
|
|
1331
|
+
days: {
|
|
1332
|
+
list: scoped.attendance.days.list
|
|
1333
|
+
.use(cap('attendance'))
|
|
1334
|
+
.use(requires('hr.attendance.view'))
|
|
1335
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1336
|
+
const where = [
|
|
1337
|
+
eq(attendanceDays.workspaceId, input.workspaceId),
|
|
1338
|
+
gte(attendanceDays.businessDate, input.from),
|
|
1339
|
+
lte(attendanceDays.businessDate, input.to),
|
|
1340
|
+
];
|
|
1341
|
+
if (input.officeId) {
|
|
1342
|
+
const here = await tx
|
|
1343
|
+
.select({ personId: officeAssignments.personId })
|
|
1344
|
+
.from(officeAssignments)
|
|
1345
|
+
.where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
|
|
1346
|
+
// Reading a whole office needs the team permission; reading yourself does not.
|
|
1347
|
+
await kernel.authz.require(context.principal, 'hr.attendance.view_team', {
|
|
1348
|
+
kind: 'workspace',
|
|
1349
|
+
id: input.workspaceId,
|
|
1350
|
+
workspaceId: input.workspaceId,
|
|
1351
|
+
});
|
|
1352
|
+
where.push(here.length
|
|
1353
|
+
? inArray(attendanceDays.personId, here.map((h) => h.personId))
|
|
1354
|
+
: sql `false`);
|
|
1355
|
+
}
|
|
1356
|
+
else {
|
|
1357
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1358
|
+
where.push(eq(attendanceDays.personId, personId));
|
|
1359
|
+
}
|
|
1360
|
+
const rows = await tx
|
|
1361
|
+
.select()
|
|
1362
|
+
.from(attendanceDays)
|
|
1363
|
+
.where(and(...where))
|
|
1364
|
+
.orderBy(asc(attendanceDays.businessDate))
|
|
1365
|
+
.limit(input.limit);
|
|
1366
|
+
return { items: rows.map(toAttendanceDay), nextCursor: null };
|
|
1367
|
+
})),
|
|
1368
|
+
recompute: scoped.attendance.days.recompute
|
|
1369
|
+
.use(cap('attendance'))
|
|
1370
|
+
.use(requires('hr.attendance.manage'))
|
|
1371
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1372
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1373
|
+
const { timezone, schedule } = await clockContext(tx, input.workspaceId, personId);
|
|
1374
|
+
const dates = await attendance.datesWithPunches(tx, input.workspaceId, personId, input.from, input.to);
|
|
1375
|
+
let recomputed = 0;
|
|
1376
|
+
const skippedLocked = [];
|
|
1377
|
+
for (const date of dates) {
|
|
1378
|
+
const r = await attendance.recomputeDay(tx, input.workspaceId, personId, date, timezone, schedule);
|
|
1379
|
+
// Named rather than silently skipped: a recomputation that quietly declines to touch
|
|
1380
|
+
// a closed month looks identical to one that had nothing to do.
|
|
1381
|
+
if (r.locked)
|
|
1382
|
+
skippedLocked.push(date);
|
|
1383
|
+
else
|
|
1384
|
+
recomputed++;
|
|
1385
|
+
}
|
|
1386
|
+
return { recomputed, skippedLocked };
|
|
1387
|
+
})),
|
|
1388
|
+
},
|
|
1389
|
+
schedules: {
|
|
1390
|
+
list: scoped.attendance.schedules.list
|
|
1391
|
+
.use(cap('attendance'))
|
|
1392
|
+
.use(requires('hr.attendance.view'))
|
|
1393
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1394
|
+
const where = [eq(schedules.workspaceId, input.workspaceId)];
|
|
1395
|
+
if (!input.includeArchived)
|
|
1396
|
+
where.push(isNull(schedules.archivedAt));
|
|
1397
|
+
const rows = await tx
|
|
1398
|
+
.select()
|
|
1399
|
+
.from(schedules)
|
|
1400
|
+
.where(and(...where))
|
|
1401
|
+
.orderBy(asc(schedules.name));
|
|
1402
|
+
return rows.map(toSchedule);
|
|
1403
|
+
})),
|
|
1404
|
+
create: scoped.attendance.schedules.create
|
|
1405
|
+
.use(cap('attendance'))
|
|
1406
|
+
.use(requires('hr.attendance.manage'))
|
|
1407
|
+
.handler(async ({ input }) => {
|
|
1408
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1409
|
+
const [created] = await tx
|
|
1410
|
+
.insert(schedules)
|
|
1411
|
+
.values({
|
|
1412
|
+
id: uuidv7(),
|
|
1413
|
+
workspaceId: input.workspaceId,
|
|
1414
|
+
name: input.name,
|
|
1415
|
+
kind: input.kind,
|
|
1416
|
+
week: input.week,
|
|
1417
|
+
tzMode: input.tzMode,
|
|
1418
|
+
tz: input.tz ?? null,
|
|
1419
|
+
graceInMinutes: input.graceInMinutes,
|
|
1420
|
+
graceOutMinutes: input.graceOutMinutes,
|
|
1421
|
+
roundingStepMinutes: input.roundingStepMinutes,
|
|
1422
|
+
roundingDirection: input.roundingDirection,
|
|
1423
|
+
autoClockOutAfterMinutes: input.autoClockOutAfterMinutes ?? null,
|
|
1424
|
+
})
|
|
1425
|
+
.returning();
|
|
1426
|
+
return created;
|
|
1427
|
+
});
|
|
1428
|
+
await changed(input.workspaceId, 'schedule', row.id, 'created');
|
|
1429
|
+
return toSchedule(row);
|
|
1430
|
+
}),
|
|
1431
|
+
update: scoped.attendance.schedules.update
|
|
1432
|
+
.use(cap('attendance'))
|
|
1433
|
+
.use(requires('hr.attendance.manage'))
|
|
1434
|
+
.handler(async ({ input }) => {
|
|
1435
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1436
|
+
const { workspaceId, scheduleId, ...patch } = input;
|
|
1437
|
+
const set = { updatedAt: new Date() };
|
|
1438
|
+
for (const [k, v] of Object.entries(patch))
|
|
1439
|
+
if (v !== undefined)
|
|
1440
|
+
set[k] = v;
|
|
1441
|
+
const [updated] = await tx
|
|
1442
|
+
.update(schedules)
|
|
1443
|
+
.set(set)
|
|
1444
|
+
.where(and(eq(schedules.workspaceId, workspaceId), eq(schedules.id, scheduleId)))
|
|
1445
|
+
.returning();
|
|
1446
|
+
if (!updated)
|
|
1447
|
+
throw KernError.notFound('Schedule');
|
|
1448
|
+
return updated;
|
|
1449
|
+
});
|
|
1450
|
+
await changed(input.workspaceId, 'schedule', row.id, 'updated');
|
|
1451
|
+
return toSchedule(row);
|
|
1452
|
+
}),
|
|
1453
|
+
archive: scoped.attendance.schedules.archive
|
|
1454
|
+
.use(cap('attendance'))
|
|
1455
|
+
.use(requires('hr.attendance.manage'))
|
|
1456
|
+
.handler(async ({ input }) => {
|
|
1457
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
1458
|
+
.update(schedules)
|
|
1459
|
+
.set({ archivedAt: new Date() })
|
|
1460
|
+
.where(and(eq(schedules.workspaceId, input.workspaceId), eq(schedules.id, input.scheduleId))));
|
|
1461
|
+
await changed(input.workspaceId, 'schedule', input.scheduleId, 'deleted');
|
|
1462
|
+
return { ok: true };
|
|
1463
|
+
}),
|
|
1464
|
+
assign: scoped.attendance.schedules.assign
|
|
1465
|
+
.use(cap('attendance'))
|
|
1466
|
+
.use(requires('hr.attendance.manage'))
|
|
1467
|
+
.handler(async ({ input }) => {
|
|
1468
|
+
const rows = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1469
|
+
// Effective-dated like everything else here: the old assignment is closed the day
|
|
1470
|
+
// before, so "which schedule was she on in March" stays answerable.
|
|
1471
|
+
await tx
|
|
1472
|
+
.update(scheduleAssignments)
|
|
1473
|
+
.set({ effectiveTo: sql `${input.effectiveFrom}::date - 1` })
|
|
1474
|
+
.where(and(eq(scheduleAssignments.workspaceId, input.workspaceId), eq(scheduleAssignments.personId, input.personId), isNull(scheduleAssignments.effectiveTo)));
|
|
1475
|
+
await tx.insert(scheduleAssignments).values({
|
|
1476
|
+
id: uuidv7(),
|
|
1477
|
+
workspaceId: input.workspaceId,
|
|
1478
|
+
personId: input.personId,
|
|
1479
|
+
scheduleId: input.scheduleId,
|
|
1480
|
+
effectiveFrom: input.effectiveFrom,
|
|
1481
|
+
});
|
|
1482
|
+
return tx
|
|
1483
|
+
.select()
|
|
1484
|
+
.from(scheduleAssignments)
|
|
1485
|
+
.where(and(eq(scheduleAssignments.workspaceId, input.workspaceId), eq(scheduleAssignments.personId, input.personId)))
|
|
1486
|
+
.orderBy(desc(scheduleAssignments.effectiveFrom));
|
|
1487
|
+
});
|
|
1488
|
+
await changed(input.workspaceId, 'schedule', input.scheduleId, 'updated');
|
|
1489
|
+
return rows.map(toScheduleAssignment);
|
|
1490
|
+
}),
|
|
1491
|
+
},
|
|
1492
|
+
regularizations: {
|
|
1493
|
+
list: scoped.attendance.regularizations.list
|
|
1494
|
+
.use(cap('attendance'))
|
|
1495
|
+
.use(requires('hr.attendance.view'))
|
|
1496
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1497
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1498
|
+
const where = [
|
|
1499
|
+
eq(regularizations.workspaceId, input.workspaceId),
|
|
1500
|
+
eq(regularizations.personId, personId),
|
|
1501
|
+
];
|
|
1502
|
+
if (input.status?.length)
|
|
1503
|
+
where.push(inArray(regularizations.status, input.status));
|
|
1504
|
+
const rows = await tx
|
|
1505
|
+
.select()
|
|
1506
|
+
.from(regularizations)
|
|
1507
|
+
.where(and(...where))
|
|
1508
|
+
.orderBy(desc(regularizations.businessDate))
|
|
1509
|
+
.limit(input.limit);
|
|
1510
|
+
return { items: rows.map(toRegularization), nextCursor: null };
|
|
1511
|
+
})),
|
|
1512
|
+
request: scoped.attendance.regularizations.request
|
|
1513
|
+
.use(cap('attendance'))
|
|
1514
|
+
.use(requires('hr.attendance.punch'))
|
|
1515
|
+
.handler(async ({ input, context }) => {
|
|
1516
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1517
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1518
|
+
const [created] = await tx
|
|
1519
|
+
.insert(regularizations)
|
|
1520
|
+
.values({
|
|
1521
|
+
id: uuidv7(),
|
|
1522
|
+
workspaceId: input.workspaceId,
|
|
1523
|
+
personId,
|
|
1524
|
+
businessDate: input.businessDate,
|
|
1525
|
+
punchId: input.punchId ?? null,
|
|
1526
|
+
proposed: input.proposed,
|
|
1527
|
+
reason: input.reason,
|
|
1528
|
+
status: 'pending',
|
|
1529
|
+
})
|
|
1530
|
+
.returning();
|
|
1531
|
+
// The same engine leave uses. That reuse is the reason approvals were built keyed by
|
|
1532
|
+
// subject type rather than bolted onto leave_requests.
|
|
1533
|
+
const raised = await approvals.raise(tx, input.workspaceId, {
|
|
1534
|
+
subjectType: 'regularization',
|
|
1535
|
+
subjectId: created.id,
|
|
1536
|
+
summary: `Correction for ${input.businessDate}`,
|
|
1537
|
+
requesterPersonId: personId,
|
|
1538
|
+
requestedBy: context.principal.userId ?? null,
|
|
1539
|
+
on: input.businessDate,
|
|
1540
|
+
});
|
|
1541
|
+
await tx
|
|
1542
|
+
.update(regularizations)
|
|
1543
|
+
.set({ approvalRequestId: raised.request.id })
|
|
1544
|
+
.where(eq(regularizations.id, created.id));
|
|
1545
|
+
if (raised.autoApproved)
|
|
1546
|
+
await applyRegularization(tx, input.workspaceId, created.id);
|
|
1547
|
+
const [fresh] = await tx
|
|
1548
|
+
.select()
|
|
1549
|
+
.from(regularizations)
|
|
1550
|
+
.where(eq(regularizations.id, created.id));
|
|
1551
|
+
return fresh;
|
|
1552
|
+
});
|
|
1553
|
+
await changed(input.workspaceId, 'regularization', row.id, 'created');
|
|
1554
|
+
return toRegularization(row);
|
|
1555
|
+
}),
|
|
1556
|
+
},
|
|
1557
|
+
},
|
|
1558
|
+
// ================================================================= leave
|
|
1559
|
+
leave: {
|
|
1560
|
+
types: {
|
|
1561
|
+
list: scoped.leave.types.list
|
|
1562
|
+
.use(cap('leave'))
|
|
1563
|
+
.use(requires('hr.leave.view'))
|
|
1564
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1565
|
+
const where = [eq(leaveTypes.workspaceId, input.workspaceId)];
|
|
1566
|
+
if (!input.includeArchived)
|
|
1567
|
+
where.push(isNull(leaveTypes.archivedAt));
|
|
1568
|
+
const rows = await tx
|
|
1569
|
+
.select()
|
|
1570
|
+
.from(leaveTypes)
|
|
1571
|
+
.where(and(...where))
|
|
1572
|
+
.orderBy(asc(leaveTypes.order), asc(leaveTypes.name));
|
|
1573
|
+
return rows.map(toLeaveType);
|
|
1574
|
+
})),
|
|
1575
|
+
create: scoped.leave.types.create
|
|
1576
|
+
.use(cap('leave'))
|
|
1577
|
+
.use(requires('hr.leave.manage'))
|
|
1578
|
+
.handler(async ({ input }) => {
|
|
1579
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1580
|
+
const [created] = await tx
|
|
1581
|
+
.insert(leaveTypes)
|
|
1582
|
+
.values({
|
|
1583
|
+
id: uuidv7(),
|
|
1584
|
+
workspaceId: input.workspaceId,
|
|
1585
|
+
key: input.key,
|
|
1586
|
+
name: input.name,
|
|
1587
|
+
paid: input.paid,
|
|
1588
|
+
unit: input.unit,
|
|
1589
|
+
color: input.color ?? null,
|
|
1590
|
+
icon: input.icon ?? null,
|
|
1591
|
+
requiresDocumentAfterDays: input.requiresDocumentAfterDays ?? null,
|
|
1592
|
+
countsWorkingDaysOnly: input.countsWorkingDaysOnly,
|
|
1593
|
+
allowNegative: input.allowNegative,
|
|
1594
|
+
maxNegativeMinutes: input.maxNegativeMinutes,
|
|
1595
|
+
})
|
|
1596
|
+
.returning();
|
|
1597
|
+
return created;
|
|
1598
|
+
});
|
|
1599
|
+
await changed(input.workspaceId, 'leave_type', row.id, 'created');
|
|
1600
|
+
return toLeaveType(row);
|
|
1601
|
+
}),
|
|
1602
|
+
update: scoped.leave.types.update
|
|
1603
|
+
.use(cap('leave'))
|
|
1604
|
+
.use(requires('hr.leave.manage'))
|
|
1605
|
+
.handler(async ({ input }) => {
|
|
1606
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1607
|
+
const { workspaceId, leaveTypeId, ...patch } = input;
|
|
1608
|
+
const set = { updatedAt: new Date() };
|
|
1609
|
+
for (const [k, v] of Object.entries(patch))
|
|
1610
|
+
if (v !== undefined)
|
|
1611
|
+
set[k] = v;
|
|
1612
|
+
const [updated] = await tx
|
|
1613
|
+
.update(leaveTypes)
|
|
1614
|
+
.set(set)
|
|
1615
|
+
.where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, leaveTypeId)))
|
|
1616
|
+
.returning();
|
|
1617
|
+
if (!updated)
|
|
1618
|
+
throw KernError.notFound('Leave type');
|
|
1619
|
+
return updated;
|
|
1620
|
+
});
|
|
1621
|
+
await changed(input.workspaceId, 'leave_type', row.id, 'updated');
|
|
1622
|
+
return toLeaveType(row);
|
|
1623
|
+
}),
|
|
1624
|
+
archive: scoped.leave.types.archive
|
|
1625
|
+
.use(cap('leave'))
|
|
1626
|
+
.use(requires('hr.leave.manage'))
|
|
1627
|
+
.handler(async ({ input }) => {
|
|
1628
|
+
// Archived, never deleted: the ledger points at it, and a balance whose type has
|
|
1629
|
+
// vanished is a number nobody can explain.
|
|
1630
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
1631
|
+
.update(leaveTypes)
|
|
1632
|
+
.set({ archivedAt: new Date() })
|
|
1633
|
+
.where(and(eq(leaveTypes.workspaceId, input.workspaceId), eq(leaveTypes.id, input.leaveTypeId))));
|
|
1634
|
+
await changed(input.workspaceId, 'leave_type', input.leaveTypeId, 'deleted');
|
|
1635
|
+
return { ok: true };
|
|
1636
|
+
}),
|
|
1637
|
+
},
|
|
1638
|
+
balance: {
|
|
1639
|
+
get: scoped.leave.balance.get
|
|
1640
|
+
.use(cap('leave'))
|
|
1641
|
+
.use(requires('hr.leave.view'))
|
|
1642
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1643
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1644
|
+
const year = input.periodYear ?? new Date().getUTCFullYear();
|
|
1645
|
+
return ledger.balances(tx, input.workspaceId, personId, year);
|
|
1646
|
+
})),
|
|
1647
|
+
},
|
|
1648
|
+
ledger: {
|
|
1649
|
+
list: scoped.leave.ledger.list
|
|
1650
|
+
.use(cap('leave'))
|
|
1651
|
+
.use(requires('hr.leave.view_ledger'))
|
|
1652
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1653
|
+
const where = [
|
|
1654
|
+
eq(leaveLedger.workspaceId, input.workspaceId),
|
|
1655
|
+
eq(leaveLedger.personId, input.personId),
|
|
1656
|
+
];
|
|
1657
|
+
if (input.leaveTypeId)
|
|
1658
|
+
where.push(eq(leaveLedger.leaveTypeId, input.leaveTypeId));
|
|
1659
|
+
if (input.periodYear)
|
|
1660
|
+
where.push(eq(leaveLedger.periodYear, input.periodYear));
|
|
1661
|
+
const rows = await tx
|
|
1662
|
+
.select()
|
|
1663
|
+
.from(leaveLedger)
|
|
1664
|
+
.where(and(...where))
|
|
1665
|
+
.orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.createdAt))
|
|
1666
|
+
.limit(input.limit);
|
|
1667
|
+
return { items: rows.map(toLedgerEntry), nextCursor: null };
|
|
1668
|
+
})),
|
|
1669
|
+
},
|
|
1670
|
+
adjust: scoped.leave.adjust
|
|
1671
|
+
.use(cap('leave'))
|
|
1672
|
+
.use(requires('hr.leave.adjust'))
|
|
1673
|
+
.handler(async ({ input, context }) => {
|
|
1674
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1675
|
+
const year = yearOf(input.effectiveOn);
|
|
1676
|
+
await ledger.lockAndRead(tx, input.workspaceId, input.personId, input.leaveTypeId, year);
|
|
1677
|
+
return ledger.append(tx, input.workspaceId, {
|
|
1678
|
+
personId: input.personId,
|
|
1679
|
+
leaveTypeId: input.leaveTypeId,
|
|
1680
|
+
kind: input.kind,
|
|
1681
|
+
amountMinutes: input.amountMinutes,
|
|
1682
|
+
effectiveOn: input.effectiveOn,
|
|
1683
|
+
periodYear: year,
|
|
1684
|
+
reason: input.reason,
|
|
1685
|
+
createdBy: context.principal.userId ?? null,
|
|
1686
|
+
});
|
|
1687
|
+
});
|
|
1688
|
+
await kernel.emit(hrEvents.leaveBalanceChanged, {
|
|
1689
|
+
workspaceId: input.workspaceId,
|
|
1690
|
+
personId: input.personId,
|
|
1691
|
+
leaveTypeId: input.leaveTypeId,
|
|
1692
|
+
deltaMinutes: input.amountMinutes,
|
|
1693
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1694
|
+
await changed(input.workspaceId, 'leave_balance', input.personId, 'updated');
|
|
1695
|
+
return toLedgerEntry(row);
|
|
1696
|
+
}),
|
|
1697
|
+
requests: {
|
|
1698
|
+
list: scoped.leave.requests.list
|
|
1699
|
+
.use(cap('leave'))
|
|
1700
|
+
.use(requires('hr.leave.view'))
|
|
1701
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1702
|
+
const where = [eq(leaveRequests.workspaceId, input.workspaceId)];
|
|
1703
|
+
if (input.personId)
|
|
1704
|
+
where.push(eq(leaveRequests.personId, input.personId));
|
|
1705
|
+
else if (!context.principal.instanceAdmin) {
|
|
1706
|
+
// Without an explicit person, this is "my requests". Seeing everybody's by default
|
|
1707
|
+
// would leak the whole company's absences to any member with hr.leave.view.
|
|
1708
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1709
|
+
where.push(me ? eq(leaveRequests.personId, me.id) : sql `false`);
|
|
1710
|
+
}
|
|
1711
|
+
if (input.status?.length)
|
|
1712
|
+
where.push(inArray(leaveRequests.status, input.status));
|
|
1713
|
+
if (input.from)
|
|
1714
|
+
where.push(gte(leaveRequests.endsOn, input.from));
|
|
1715
|
+
if (input.to)
|
|
1716
|
+
where.push(lte(leaveRequests.startsOn, input.to));
|
|
1717
|
+
const rows = await tx
|
|
1718
|
+
.select()
|
|
1719
|
+
.from(leaveRequests)
|
|
1720
|
+
.where(and(...where))
|
|
1721
|
+
.orderBy(desc(leaveRequests.startsOn))
|
|
1722
|
+
.limit(input.limit);
|
|
1723
|
+
return { items: rows.map(toLeaveRequest), nextCursor: null };
|
|
1724
|
+
})),
|
|
1725
|
+
get: scoped.leave.requests.get
|
|
1726
|
+
.use(cap('leave'))
|
|
1727
|
+
.use(requires('hr.leave.view'))
|
|
1728
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => toLeaveRequest(await loadRequest(tx, input.workspaceId, input.requestId)))),
|
|
1729
|
+
simulate: scoped.leave.requests.simulate
|
|
1730
|
+
.use(cap('leave'))
|
|
1731
|
+
.use(requires('hr.leave.request'))
|
|
1732
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1733
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1734
|
+
return simulate(tx, input.workspaceId, personId, input);
|
|
1735
|
+
})),
|
|
1736
|
+
create: scoped.leave.requests.create
|
|
1737
|
+
.use(cap('leave'))
|
|
1738
|
+
.use(requires('hr.leave.request'))
|
|
1739
|
+
.handler(async ({ input, context }) => {
|
|
1740
|
+
const result = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1741
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1742
|
+
// Everything that spends balance takes the cursor lock first, inside this
|
|
1743
|
+
// transaction. Two overlapping requests for the last day cannot both read "enough".
|
|
1744
|
+
const year = yearOf(input.startsOn);
|
|
1745
|
+
await ledger.lockAndRead(tx, input.workspaceId, personId, input.leaveTypeId, year);
|
|
1746
|
+
const sim = await simulate(tx, input.workspaceId, personId, input);
|
|
1747
|
+
if (sim.blockers.length)
|
|
1748
|
+
throw KernError.conflict(sim.blockers[0].message, `hr.leave.${sim.blockers[0].code}`);
|
|
1749
|
+
const [request] = await tx
|
|
1750
|
+
.insert(leaveRequests)
|
|
1751
|
+
.values({
|
|
1752
|
+
id: uuidv7(),
|
|
1753
|
+
workspaceId: input.workspaceId,
|
|
1754
|
+
personId,
|
|
1755
|
+
leaveTypeId: input.leaveTypeId,
|
|
1756
|
+
startsOn: input.startsOn,
|
|
1757
|
+
endsOn: input.endsOn,
|
|
1758
|
+
startPart: input.startPart,
|
|
1759
|
+
endPart: input.endPart,
|
|
1760
|
+
hours: input.hours === null || input.hours === undefined ? null : String(input.hours),
|
|
1761
|
+
workingDays: String(sim.workingDays),
|
|
1762
|
+
minutes: sim.minutes,
|
|
1763
|
+
status: 'pending',
|
|
1764
|
+
reason: input.reason ?? null,
|
|
1765
|
+
documentFileId: input.documentFileId ?? null,
|
|
1766
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
1767
|
+
})
|
|
1768
|
+
.returning();
|
|
1769
|
+
// The exploded days are what the partial unique index guards, so this insert is what
|
|
1770
|
+
// actually refuses a double booking — before any approval happens.
|
|
1771
|
+
await tx.insert(leaveRequestDays).values(sim.days.map((d) => ({
|
|
1772
|
+
id: uuidv7(),
|
|
1773
|
+
workspaceId: input.workspaceId,
|
|
1774
|
+
requestId: request.id,
|
|
1775
|
+
personId,
|
|
1776
|
+
date: d.date,
|
|
1777
|
+
fraction: String(d.fraction),
|
|
1778
|
+
counted: d.counted,
|
|
1779
|
+
status: 'pending',
|
|
1780
|
+
})));
|
|
1781
|
+
const raised = await approvals.raise(tx, input.workspaceId, {
|
|
1782
|
+
subjectType: 'leave',
|
|
1783
|
+
subjectId: request.id,
|
|
1784
|
+
summary: `${sim.workingDays} day(s) from ${input.startsOn}`,
|
|
1785
|
+
requesterPersonId: personId,
|
|
1786
|
+
requestedBy: context.principal.userId ?? null,
|
|
1787
|
+
on: input.startsOn,
|
|
1788
|
+
});
|
|
1789
|
+
await tx
|
|
1790
|
+
.update(leaveRequests)
|
|
1791
|
+
.set({ approvalRequestId: raised.request.id })
|
|
1792
|
+
.where(eq(leaveRequests.id, request.id));
|
|
1793
|
+
// A chain that resolves to nobody approves immediately — a one-person company has no
|
|
1794
|
+
// manager and still has to be able to book time off.
|
|
1795
|
+
if (raised.autoApproved)
|
|
1796
|
+
await applyApproval(tx, input.workspaceId, request.id, context.principal.userId ?? null);
|
|
1797
|
+
const [fresh] = await tx.select().from(leaveRequests).where(eq(leaveRequests.id, request.id));
|
|
1798
|
+
return { request: fresh, approvers: raised.firstStepApprovers, personId };
|
|
1799
|
+
});
|
|
1800
|
+
await kernel.emit(hrEvents.leaveRequested, {
|
|
1801
|
+
requestId: result.request.id,
|
|
1802
|
+
workspaceId: input.workspaceId,
|
|
1803
|
+
personId: result.personId,
|
|
1804
|
+
startsOn: input.startsOn,
|
|
1805
|
+
endsOn: input.endsOn,
|
|
1806
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1807
|
+
await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
|
|
1808
|
+
return toLeaveRequest(result.request);
|
|
1809
|
+
}),
|
|
1810
|
+
cancel: scoped.leave.requests.cancel
|
|
1811
|
+
.use(cap('leave'))
|
|
1812
|
+
.use(requires('hr.leave.request'))
|
|
1813
|
+
.handler(async ({ input, context }) => {
|
|
1814
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1815
|
+
const request = await loadRequest(tx, input.workspaceId, input.requestId);
|
|
1816
|
+
if (request.status === 'cancelled' || request.status === 'withdrawn')
|
|
1817
|
+
throw KernError.conflict('That request is already cancelled');
|
|
1818
|
+
const year = yearOf(request.startsOn);
|
|
1819
|
+
await ledger.lockAndRead(tx, input.workspaceId, request.personId, request.leaveTypeId, year);
|
|
1820
|
+
// Approved leave is *reversed*, not deleted. "She booked it and cancelled" and "she
|
|
1821
|
+
// never booked it" are different facts, and only one of them is true.
|
|
1822
|
+
if (request.status === 'approved')
|
|
1823
|
+
for (const entry of await ledger.entriesFor(tx, input.workspaceId, request.id))
|
|
1824
|
+
if (entry.kind === 'consumption')
|
|
1825
|
+
await ledger.reverse(tx, input.workspaceId, entry.id, input.reason ?? 'Leave cancelled', context.principal.userId ?? null, todayIso());
|
|
1826
|
+
await approvals.cancel(tx, input.workspaceId, 'leave', request.id);
|
|
1827
|
+
const next = request.status === 'approved' ? 'withdrawn' : 'cancelled';
|
|
1828
|
+
await tx
|
|
1829
|
+
.update(leaveRequestDays)
|
|
1830
|
+
.set({ status: next })
|
|
1831
|
+
.where(eq(leaveRequestDays.requestId, request.id));
|
|
1832
|
+
const [updated] = await tx
|
|
1833
|
+
.update(leaveRequests)
|
|
1834
|
+
.set({ status: next, decidedAt: new Date(), updatedAt: new Date() })
|
|
1835
|
+
.where(eq(leaveRequests.id, request.id))
|
|
1836
|
+
.returning();
|
|
1837
|
+
return updated;
|
|
1838
|
+
});
|
|
1839
|
+
await kernel.emit(hrEvents.leaveDecided, {
|
|
1840
|
+
requestId: row.id,
|
|
1841
|
+
workspaceId: input.workspaceId,
|
|
1842
|
+
personId: row.personId,
|
|
1843
|
+
status: row.status,
|
|
1844
|
+
startsOn: row.startsOn,
|
|
1845
|
+
endsOn: row.endsOn,
|
|
1846
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1847
|
+
await changed(input.workspaceId, 'leave_request', row.id, 'updated');
|
|
1848
|
+
return toLeaveRequest(row);
|
|
1849
|
+
}),
|
|
1850
|
+
},
|
|
1851
|
+
team: {
|
|
1852
|
+
calendar: scoped.leave.team.calendar
|
|
1853
|
+
.use(cap('leave'))
|
|
1854
|
+
.use(requires('hr.leave.view_team'))
|
|
1855
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1856
|
+
const rows = await tx
|
|
1857
|
+
.select()
|
|
1858
|
+
.from(leaveRequests)
|
|
1859
|
+
.where(and(eq(leaveRequests.workspaceId, input.workspaceId), inArray(leaveRequests.status, ['pending', 'approved']), lte(leaveRequests.startsOn, input.to), gte(leaveRequests.endsOn, input.from)));
|
|
1860
|
+
if (!rows.length)
|
|
1861
|
+
return [];
|
|
1862
|
+
const personIds = [...new Set(rows.map((r) => r.personId))];
|
|
1863
|
+
const persons = await tx
|
|
1864
|
+
.select({ id: people.id, displayName: people.displayName })
|
|
1865
|
+
.from(people)
|
|
1866
|
+
.where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, personIds)));
|
|
1867
|
+
const nameById = new Map(persons.map((p) => [p.id, p.displayName]));
|
|
1868
|
+
const types = await tx
|
|
1869
|
+
.select()
|
|
1870
|
+
.from(leaveTypes)
|
|
1871
|
+
.where(eq(leaveTypes.workspaceId, input.workspaceId));
|
|
1872
|
+
const typeById = new Map(types.map((t) => [t.id, t]));
|
|
1873
|
+
// Most companies want the team to know somebody is away without knowing it is sick
|
|
1874
|
+
// leave, so the type is named only for somebody who may read the ledger.
|
|
1875
|
+
const maySeeType = await kernel.authz.can(context.principal, 'hr.leave.view_ledger', {
|
|
1876
|
+
kind: 'workspace',
|
|
1877
|
+
id: input.workspaceId,
|
|
1878
|
+
workspaceId: input.workspaceId,
|
|
1879
|
+
});
|
|
1880
|
+
let filtered = rows;
|
|
1881
|
+
if (input.officeId) {
|
|
1882
|
+
const here = await tx
|
|
1883
|
+
.select({ personId: officeAssignments.personId })
|
|
1884
|
+
.from(officeAssignments)
|
|
1885
|
+
.where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
|
|
1886
|
+
const ids = new Set(here.map((h) => h.personId));
|
|
1887
|
+
filtered = filtered.filter((r) => ids.has(r.personId));
|
|
1888
|
+
}
|
|
1889
|
+
if (input.orgUnitId) {
|
|
1890
|
+
const ids = new Set(await unitMemberIds(tx, input.workspaceId, input.orgUnitId, true));
|
|
1891
|
+
filtered = filtered.filter((r) => ids.has(r.personId));
|
|
1892
|
+
}
|
|
1893
|
+
return filtered.map((r) => {
|
|
1894
|
+
const type = typeById.get(r.leaveTypeId);
|
|
1895
|
+
return {
|
|
1896
|
+
personId: r.personId,
|
|
1897
|
+
displayName: nameById.get(r.personId) ?? 'Unknown',
|
|
1898
|
+
requestId: r.id,
|
|
1899
|
+
startsOn: r.startsOn,
|
|
1900
|
+
endsOn: r.endsOn,
|
|
1901
|
+
status: r.status,
|
|
1902
|
+
leaveTypeName: maySeeType ? (type?.name ?? null) : null,
|
|
1903
|
+
color: type?.color ?? null,
|
|
1904
|
+
};
|
|
1905
|
+
});
|
|
1906
|
+
})),
|
|
1907
|
+
},
|
|
1908
|
+
},
|
|
1909
|
+
// ================================================================= approvals
|
|
1910
|
+
approvals: {
|
|
1911
|
+
/**
|
|
1912
|
+
* Everything waiting on the caller. No permission: an inbox of what *you* must decide is
|
|
1913
|
+
* yours by definition, and the engine only lists steps you are named on.
|
|
1914
|
+
*/
|
|
1915
|
+
inbox: scoped.approvals.inbox.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1916
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1917
|
+
if (!me)
|
|
1918
|
+
return { items: [], nextCursor: null };
|
|
1919
|
+
const rows = await approvals.inboxFor(tx, input.workspaceId, me.id, input.includeDecided, input.limit);
|
|
1920
|
+
const items = [];
|
|
1921
|
+
for (const r of rows)
|
|
1922
|
+
items.push(await hydrateApproval(tx, r));
|
|
1923
|
+
return { items, nextCursor: null };
|
|
1924
|
+
})),
|
|
1925
|
+
get: scoped.approvals.get.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1926
|
+
const [row] = await tx
|
|
1927
|
+
.select()
|
|
1928
|
+
.from(approvalRequests)
|
|
1929
|
+
.where(and(eq(approvalRequests.workspaceId, input.workspaceId), eq(approvalRequests.id, input.requestId)))
|
|
1930
|
+
.limit(1);
|
|
1931
|
+
if (!row)
|
|
1932
|
+
throw KernError.notFound('Approval request');
|
|
1933
|
+
return hydrateApproval(tx, row);
|
|
1934
|
+
})),
|
|
1935
|
+
decide: scoped.approvals.decide.handler(async ({ input, context }) => {
|
|
1936
|
+
const outcome = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1937
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
1938
|
+
if (!me)
|
|
1939
|
+
throw KernError.forbidden('You have no employee record in this workspace');
|
|
1940
|
+
const result = await approvals.decide(tx, input.workspaceId, input.requestId, me.id, input.decision, input.comment ?? null, input.onBehalfOfId ?? null);
|
|
1941
|
+
// The approval engine knows nothing about leave. Applying the decision to the subject is
|
|
1942
|
+
// the caller's job, which is what keeps the engine reusable for regularization and
|
|
1943
|
+
// overtime later.
|
|
1944
|
+
const request = result.request;
|
|
1945
|
+
if (request.subjectType === 'leave') {
|
|
1946
|
+
if (result.status === 'approved')
|
|
1947
|
+
await applyApproval(tx, input.workspaceId, request.subjectId, context.principal.userId ?? null);
|
|
1948
|
+
else if (result.status === 'rejected')
|
|
1949
|
+
await tx
|
|
1950
|
+
.update(leaveRequests)
|
|
1951
|
+
.set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
|
|
1952
|
+
.where(eq(leaveRequests.id, request.subjectId));
|
|
1953
|
+
}
|
|
1954
|
+
else if (request.subjectType === 'regularization') {
|
|
1955
|
+
if (result.status === 'approved')
|
|
1956
|
+
await applyRegularization(tx, input.workspaceId, request.subjectId);
|
|
1957
|
+
else if (result.status === 'rejected')
|
|
1958
|
+
await tx
|
|
1959
|
+
.update(regularizations)
|
|
1960
|
+
.set({ status: 'rejected' })
|
|
1961
|
+
.where(eq(regularizations.id, request.subjectId));
|
|
1962
|
+
}
|
|
1963
|
+
const [fresh] = await tx
|
|
1964
|
+
.select()
|
|
1965
|
+
.from(approvalRequests)
|
|
1966
|
+
.where(eq(approvalRequests.id, input.requestId));
|
|
1967
|
+
return { hydrated: await hydrateApproval(tx, fresh), request: fresh };
|
|
1968
|
+
});
|
|
1969
|
+
await kernel.emit(hrEvents.approvalDecided, {
|
|
1970
|
+
requestId: outcome.request.id,
|
|
1971
|
+
workspaceId: input.workspaceId,
|
|
1972
|
+
subjectType: outcome.request.subjectType,
|
|
1973
|
+
subjectId: outcome.request.subjectId,
|
|
1974
|
+
status: outcome.request.status,
|
|
1975
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
1976
|
+
await changed(input.workspaceId, 'approval', outcome.request.id, 'updated');
|
|
1977
|
+
return outcome.hydrated;
|
|
1978
|
+
}),
|
|
1979
|
+
chains: {
|
|
1980
|
+
list: scoped.approvals.chains.list
|
|
1981
|
+
.use(cap('approvals'))
|
|
1982
|
+
.use(requires('hr.approval.manage'))
|
|
1983
|
+
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1984
|
+
const where = [
|
|
1985
|
+
eq(approvalChains.workspaceId, input.workspaceId),
|
|
1986
|
+
isNull(approvalChains.archivedAt),
|
|
1987
|
+
];
|
|
1988
|
+
if (input.subjectType)
|
|
1989
|
+
where.push(eq(approvalChains.subjectType, input.subjectType));
|
|
1990
|
+
const rows = await tx
|
|
1991
|
+
.select()
|
|
1992
|
+
.from(approvalChains)
|
|
1993
|
+
.where(and(...where))
|
|
1994
|
+
.orderBy(asc(approvalChains.name));
|
|
1995
|
+
return rows.map(toChain);
|
|
1996
|
+
})),
|
|
1997
|
+
create: scoped.approvals.chains.create
|
|
1998
|
+
.use(cap('approvals'))
|
|
1999
|
+
.use(requires('hr.approval.manage'))
|
|
2000
|
+
.handler(async ({ input }) => {
|
|
2001
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2002
|
+
if (input.isDefault)
|
|
2003
|
+
await clearDefaultChain(tx, input.workspaceId, input.subjectType);
|
|
2004
|
+
const [created] = await tx
|
|
2005
|
+
.insert(approvalChains)
|
|
2006
|
+
.values({
|
|
2007
|
+
id: uuidv7(),
|
|
2008
|
+
workspaceId: input.workspaceId,
|
|
2009
|
+
name: input.name,
|
|
2010
|
+
subjectType: input.subjectType,
|
|
2011
|
+
spec: input.spec,
|
|
2012
|
+
isDefault: input.isDefault,
|
|
2013
|
+
})
|
|
2014
|
+
.returning();
|
|
2015
|
+
return created;
|
|
2016
|
+
});
|
|
2017
|
+
await changed(input.workspaceId, 'approval_chain', row.id, 'created');
|
|
2018
|
+
return toChain(row);
|
|
2019
|
+
}),
|
|
2020
|
+
update: scoped.approvals.chains.update
|
|
2021
|
+
.use(cap('approvals'))
|
|
2022
|
+
.use(requires('hr.approval.manage'))
|
|
2023
|
+
.handler(async ({ input }) => {
|
|
2024
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2025
|
+
const [existing] = await tx
|
|
2026
|
+
.select()
|
|
2027
|
+
.from(approvalChains)
|
|
2028
|
+
.where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId)))
|
|
2029
|
+
.limit(1);
|
|
2030
|
+
if (!existing)
|
|
2031
|
+
throw KernError.notFound('Approval chain');
|
|
2032
|
+
if (input.isDefault)
|
|
2033
|
+
await clearDefaultChain(tx, input.workspaceId, existing.subjectType);
|
|
2034
|
+
const set = { updatedAt: new Date() };
|
|
2035
|
+
if (input.name !== undefined)
|
|
2036
|
+
set.name = input.name;
|
|
2037
|
+
if (input.spec !== undefined)
|
|
2038
|
+
set.spec = input.spec;
|
|
2039
|
+
if (input.isDefault !== undefined)
|
|
2040
|
+
set.isDefault = input.isDefault;
|
|
2041
|
+
const [updated] = await tx
|
|
2042
|
+
.update(approvalChains)
|
|
2043
|
+
.set(set)
|
|
2044
|
+
.where(eq(approvalChains.id, input.chainId))
|
|
2045
|
+
.returning();
|
|
2046
|
+
return updated;
|
|
2047
|
+
});
|
|
2048
|
+
await changed(input.workspaceId, 'approval_chain', row.id, 'updated');
|
|
2049
|
+
return toChain(row);
|
|
2050
|
+
}),
|
|
2051
|
+
archive: scoped.approvals.chains.archive
|
|
2052
|
+
.use(cap('approvals'))
|
|
2053
|
+
.use(requires('hr.approval.manage'))
|
|
2054
|
+
.handler(async ({ input }) => {
|
|
2055
|
+
// In-flight requests carry their own snapshot of the chain, so archiving one cannot
|
|
2056
|
+
// strand an approval half-signed.
|
|
2057
|
+
await db.withWorkspace(input.workspaceId, (tx) => tx
|
|
2058
|
+
.update(approvalChains)
|
|
2059
|
+
.set({ archivedAt: new Date(), isDefault: false })
|
|
2060
|
+
.where(and(eq(approvalChains.workspaceId, input.workspaceId), eq(approvalChains.id, input.chainId))));
|
|
2061
|
+
await changed(input.workspaceId, 'approval_chain', input.chainId, 'deleted');
|
|
2062
|
+
return { ok: true };
|
|
2063
|
+
}),
|
|
2064
|
+
},
|
|
2065
|
+
delegations: scoped.approvals.delegations
|
|
2066
|
+
.use(cap('approvals'))
|
|
2067
|
+
.use(requires('hr.approval.delegate'))
|
|
2068
|
+
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2069
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
2070
|
+
const rows = await tx
|
|
2071
|
+
.select()
|
|
2072
|
+
.from(delegations)
|
|
2073
|
+
.where(and(eq(delegations.workspaceId, input.workspaceId), or(eq(delegations.fromPersonId, personId), eq(delegations.toPersonId, personId))))
|
|
2074
|
+
.orderBy(desc(delegations.startsOn));
|
|
2075
|
+
return rows.map(toDelegation);
|
|
2076
|
+
})),
|
|
2077
|
+
delegate: scoped.approvals.delegate
|
|
2078
|
+
.use(cap('approvals'))
|
|
2079
|
+
.use(requires('hr.approval.delegate'))
|
|
2080
|
+
.handler(async ({ input, context }) => {
|
|
2081
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2082
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
2083
|
+
if (!me)
|
|
2084
|
+
throw KernError.forbidden('You have no employee record in this workspace');
|
|
2085
|
+
if (me.id === input.toPersonId)
|
|
2086
|
+
throw KernError.badRequest('You cannot delegate your approvals to yourself.');
|
|
2087
|
+
if (input.endsOn < input.startsOn)
|
|
2088
|
+
throw KernError.badRequest('A delegation cannot end before it starts.');
|
|
2089
|
+
const [created] = await tx
|
|
2090
|
+
.insert(delegations)
|
|
2091
|
+
.values({
|
|
2092
|
+
id: uuidv7(),
|
|
2093
|
+
workspaceId: input.workspaceId,
|
|
2094
|
+
fromPersonId: me.id,
|
|
2095
|
+
toPersonId: input.toPersonId,
|
|
2096
|
+
subjectType: input.subjectType ?? null,
|
|
2097
|
+
startsOn: input.startsOn,
|
|
2098
|
+
endsOn: input.endsOn,
|
|
2099
|
+
reason: input.reason ?? null,
|
|
2100
|
+
})
|
|
2101
|
+
.returning();
|
|
2102
|
+
return created;
|
|
2103
|
+
});
|
|
2104
|
+
await changed(input.workspaceId, 'delegation', row.id, 'created');
|
|
2105
|
+
return toDelegation(row);
|
|
2106
|
+
}),
|
|
2107
|
+
revokeDelegation: scoped.approvals.revokeDelegation
|
|
2108
|
+
.use(cap('approvals'))
|
|
2109
|
+
.use(requires('hr.approval.delegate'))
|
|
2110
|
+
.handler(async ({ input, context }) => {
|
|
2111
|
+
await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2112
|
+
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
2113
|
+
const [row] = await tx
|
|
2114
|
+
.select()
|
|
2115
|
+
.from(delegations)
|
|
2116
|
+
.where(and(eq(delegations.workspaceId, input.workspaceId), eq(delegations.id, input.delegationId)))
|
|
2117
|
+
.limit(1);
|
|
2118
|
+
if (!row)
|
|
2119
|
+
throw KernError.notFound('Delegation');
|
|
2120
|
+
// Only the person who gave it away may take it back — otherwise a delegate could quietly
|
|
2121
|
+
// extend their own authority by revoking the competition.
|
|
2122
|
+
if (row.fromPersonId !== me?.id)
|
|
2123
|
+
throw KernError.forbidden('Only the person who delegated may revoke it');
|
|
2124
|
+
await tx.delete(delegations).where(eq(delegations.id, row.id));
|
|
2125
|
+
});
|
|
2126
|
+
await changed(input.workspaceId, 'delegation', input.delegationId, 'deleted');
|
|
2127
|
+
return { ok: true };
|
|
2128
|
+
}),
|
|
2129
|
+
},
|
|
1223
2130
|
// ================================================================= custom fields
|
|
1224
2131
|
fields: {
|
|
1225
2132
|
list: scoped.fields.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
@@ -1495,6 +2402,334 @@ export function implement_(kernel) {
|
|
|
1495
2402
|
const parent = await parentPath(tx, workspaceId, parentId);
|
|
1496
2403
|
return parent ? `${parent}.${label}` : label;
|
|
1497
2404
|
}
|
|
2405
|
+
/**
|
|
2406
|
+
* The person a call is about: the one named, or the caller.
|
|
2407
|
+
*
|
|
2408
|
+
* Reading somebody else's balance needs `hr.leave.view_team`; reading your own needs nothing
|
|
2409
|
+
* beyond being an employee. Collapsing those into one permission would either hide your own
|
|
2410
|
+
* balance from you or show you everybody's.
|
|
2411
|
+
*/
|
|
2412
|
+
async function personFor(tx, workspaceId, context, personId) {
|
|
2413
|
+
const me = await svc.byUserId(tx, workspaceId, context.principal.userId ?? '');
|
|
2414
|
+
if (!personId) {
|
|
2415
|
+
if (!me)
|
|
2416
|
+
throw KernError.notFound('Your employee record');
|
|
2417
|
+
return me.id;
|
|
2418
|
+
}
|
|
2419
|
+
if (me && me.id === personId)
|
|
2420
|
+
return personId;
|
|
2421
|
+
await kernel.authz.require(context.principal, 'hr.leave.view_team', {
|
|
2422
|
+
kind: 'workspace',
|
|
2423
|
+
id: workspaceId,
|
|
2424
|
+
workspaceId,
|
|
2425
|
+
});
|
|
2426
|
+
return personId;
|
|
2427
|
+
}
|
|
2428
|
+
async function loadRequest(tx, workspaceId, requestId) {
|
|
2429
|
+
const [row] = await tx
|
|
2430
|
+
.select()
|
|
2431
|
+
.from(leaveRequests)
|
|
2432
|
+
.where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
|
|
2433
|
+
.limit(1);
|
|
2434
|
+
if (!row)
|
|
2435
|
+
throw KernError.notFound('Leave request');
|
|
2436
|
+
return row;
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* What a request would cost, and every reason it would be refused.
|
|
2440
|
+
*
|
|
2441
|
+
* Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
|
|
2442
|
+
* submission is a preview that eventually lies. The blockers are returned rather than thrown here
|
|
2443
|
+
* so the screen can show all of them at once instead of one per round trip.
|
|
2444
|
+
*/
|
|
2445
|
+
async function simulate(tx, workspaceId, personId, input) {
|
|
2446
|
+
const blockers = [];
|
|
2447
|
+
if (input.endsOn < input.startsOn)
|
|
2448
|
+
blockers.push({ code: 'range', message: 'The end date is before the start date.' });
|
|
2449
|
+
const [type] = await tx
|
|
2450
|
+
.select()
|
|
2451
|
+
.from(leaveTypes)
|
|
2452
|
+
.where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
|
|
2453
|
+
.limit(1);
|
|
2454
|
+
if (!type)
|
|
2455
|
+
throw KernError.notFound('Leave type');
|
|
2456
|
+
if (type.archivedAt)
|
|
2457
|
+
blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
|
|
2458
|
+
const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
|
|
2459
|
+
const calendarDaysInRange = resolution.calendarId
|
|
2460
|
+
? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
|
|
2461
|
+
: [];
|
|
2462
|
+
const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
|
|
2463
|
+
? calendarDaysInRange.map((d) => ({
|
|
2464
|
+
date: d.date,
|
|
2465
|
+
name: d.name,
|
|
2466
|
+
workingFraction: d.workingFraction,
|
|
2467
|
+
}))
|
|
2468
|
+
: []);
|
|
2469
|
+
// Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
|
|
2470
|
+
// holiday still costs nothing rather than costing half of nothing.
|
|
2471
|
+
const days = results.map((r) => {
|
|
2472
|
+
let fraction = r.fraction;
|
|
2473
|
+
if (r.date === input.startsOn && input.startPart === 'afternoon')
|
|
2474
|
+
fraction = Math.min(fraction, 0.5);
|
|
2475
|
+
if (r.date === input.endsOn && input.endPart === 'morning')
|
|
2476
|
+
fraction = Math.min(fraction, 0.5);
|
|
2477
|
+
return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
|
|
2478
|
+
});
|
|
2479
|
+
const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
|
|
2480
|
+
const minutes = type.unit === 'hour' && input.hours
|
|
2481
|
+
? Math.round(input.hours * 60)
|
|
2482
|
+
: Math.round(workingDaysTotal * MINUTES_PER_DAY);
|
|
2483
|
+
if (minutes <= 0)
|
|
2484
|
+
blockers.push({
|
|
2485
|
+
code: 'empty',
|
|
2486
|
+
message: 'That range contains no working days.',
|
|
2487
|
+
});
|
|
2488
|
+
const year = yearOf(input.startsOn);
|
|
2489
|
+
const balances = await ledger.balances(tx, workspaceId, personId, year);
|
|
2490
|
+
const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
|
|
2491
|
+
const before = balance?.availableMinutes ?? 0;
|
|
2492
|
+
const after = before - minutes;
|
|
2493
|
+
if (after < 0 && !type.allowNegative)
|
|
2494
|
+
blockers.push({
|
|
2495
|
+
code: 'insufficient',
|
|
2496
|
+
message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
|
|
2497
|
+
});
|
|
2498
|
+
if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
|
|
2499
|
+
blockers.push({
|
|
2500
|
+
code: 'below_floor',
|
|
2501
|
+
message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
|
|
2502
|
+
});
|
|
2503
|
+
// Overlap is refused by a unique index as well; checking here turns a constraint violation into
|
|
2504
|
+
// a sentence naming the dates.
|
|
2505
|
+
const counted = days.filter((d) => d.counted).map((d) => d.date);
|
|
2506
|
+
if (counted.length) {
|
|
2507
|
+
const clash = await tx
|
|
2508
|
+
.select({ date: leaveRequestDays.date })
|
|
2509
|
+
.from(leaveRequestDays)
|
|
2510
|
+
.where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
|
|
2511
|
+
.limit(3);
|
|
2512
|
+
if (clash.length)
|
|
2513
|
+
blockers.push({
|
|
2514
|
+
code: 'overlap',
|
|
2515
|
+
message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
|
|
2519
|
+
blockers.push({
|
|
2520
|
+
code: 'document_required',
|
|
2521
|
+
message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
|
|
2522
|
+
});
|
|
2523
|
+
return {
|
|
2524
|
+
workingDays: workingDaysTotal,
|
|
2525
|
+
minutes,
|
|
2526
|
+
days,
|
|
2527
|
+
balanceBeforeMinutes: before,
|
|
2528
|
+
balanceAfterMinutes: after,
|
|
2529
|
+
blockers,
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Turn an approved request into a ledger consumption.
|
|
2534
|
+
*
|
|
2535
|
+
* The working days are **recomputed here** rather than trusted from submission time: a holiday
|
|
2536
|
+
* can be added to the calendar between asking and approving, and the number that costs somebody
|
|
2537
|
+
* balance should be the one that was true when it was granted.
|
|
2538
|
+
*/
|
|
2539
|
+
async function applyApproval(tx, workspaceId, leaveRequestId, actorId) {
|
|
2540
|
+
const request = await loadRequest(tx, workspaceId, leaveRequestId);
|
|
2541
|
+
if (request.status === 'approved')
|
|
2542
|
+
return;
|
|
2543
|
+
const sim = await simulate(tx, workspaceId, request.personId, {
|
|
2544
|
+
leaveTypeId: request.leaveTypeId,
|
|
2545
|
+
startsOn: request.startsOn,
|
|
2546
|
+
endsOn: request.endsOn,
|
|
2547
|
+
startPart: request.startPart,
|
|
2548
|
+
endPart: request.endPart,
|
|
2549
|
+
hours: request.hours === null ? null : Number.parseFloat(request.hours),
|
|
2550
|
+
});
|
|
2551
|
+
await ledger.append(tx, workspaceId, {
|
|
2552
|
+
personId: request.personId,
|
|
2553
|
+
leaveTypeId: request.leaveTypeId,
|
|
2554
|
+
kind: 'consumption',
|
|
2555
|
+
amountMinutes: -sim.minutes,
|
|
2556
|
+
effectiveOn: request.startsOn,
|
|
2557
|
+
periodYear: yearOf(request.startsOn),
|
|
2558
|
+
requestId: request.id,
|
|
2559
|
+
reason: null,
|
|
2560
|
+
createdBy: actorId,
|
|
2561
|
+
});
|
|
2562
|
+
await tx
|
|
2563
|
+
.update(leaveRequestDays)
|
|
2564
|
+
.set({ status: 'approved' })
|
|
2565
|
+
.where(eq(leaveRequestDays.requestId, request.id));
|
|
2566
|
+
await tx
|
|
2567
|
+
.update(leaveRequests)
|
|
2568
|
+
.set({
|
|
2569
|
+
status: 'approved',
|
|
2570
|
+
minutes: sim.minutes,
|
|
2571
|
+
workingDays: String(sim.workingDays),
|
|
2572
|
+
decidedAt: new Date(),
|
|
2573
|
+
updatedAt: new Date(),
|
|
2574
|
+
})
|
|
2575
|
+
.where(eq(leaveRequests.id, request.id));
|
|
2576
|
+
}
|
|
2577
|
+
async function clearDefaultChain(tx, workspaceId, subjectType) {
|
|
2578
|
+
await tx
|
|
2579
|
+
.update(approvalChains)
|
|
2580
|
+
.set({ isDefault: false })
|
|
2581
|
+
.where(and(eq(approvalChains.workspaceId, workspaceId), eq(approvalChains.subjectType, subjectType), eq(approvalChains.isDefault, true)));
|
|
2582
|
+
}
|
|
2583
|
+
/** An approval request with its steps and decisions, which is the only useful shape. */
|
|
2584
|
+
async function hydrateApproval(tx, row) {
|
|
2585
|
+
const steps = await tx
|
|
2586
|
+
.select()
|
|
2587
|
+
.from(approvalSteps)
|
|
2588
|
+
.where(eq(approvalSteps.requestId, row.id))
|
|
2589
|
+
.orderBy(asc(approvalSteps.stepIndex));
|
|
2590
|
+
const stepIds = steps.map((s) => s.id);
|
|
2591
|
+
const decisions = stepIds.length
|
|
2592
|
+
? await tx.select().from(approvalDecisions).where(inArray(approvalDecisions.stepId, stepIds))
|
|
2593
|
+
: [];
|
|
2594
|
+
return {
|
|
2595
|
+
...row,
|
|
2596
|
+
subjectType: row.subjectType,
|
|
2597
|
+
status: row.status,
|
|
2598
|
+
requestedAt: row.requestedAt.toISOString(),
|
|
2599
|
+
decidedAt: row.decidedAt?.toISOString() ?? null,
|
|
2600
|
+
steps: steps.map((s) => ({
|
|
2601
|
+
...s,
|
|
2602
|
+
mode: s.mode,
|
|
2603
|
+
status: s.status,
|
|
2604
|
+
dueAt: s.dueAt?.toISOString() ?? null,
|
|
2605
|
+
escalatedAt: s.escalatedAt?.toISOString() ?? null,
|
|
2606
|
+
decisions: decisions
|
|
2607
|
+
.filter((d) => d.stepId === s.id)
|
|
2608
|
+
.map((d) => ({
|
|
2609
|
+
...d,
|
|
2610
|
+
decision: d.decision,
|
|
2611
|
+
at: d.at.toISOString(),
|
|
2612
|
+
})),
|
|
2613
|
+
})),
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* Everything a punch needs: the person's zone, today's business date, and their schedule.
|
|
2618
|
+
*
|
|
2619
|
+
* The zone comes from the resolution ladder — their primary office unless they have an override —
|
|
2620
|
+
* so a punch made on a business trip still counts towards the month they are employed in.
|
|
2621
|
+
*/
|
|
2622
|
+
async function clockContext(tx, workspaceId, personId) {
|
|
2623
|
+
const today = todayIso();
|
|
2624
|
+
const resolution = await resolve.forPerson(tx, workspaceId, personId, today);
|
|
2625
|
+
const schedule = await attendance.scheduleFor(tx, workspaceId, personId, today);
|
|
2626
|
+
const businessDate = businessDateFor(Date.now(), resolution.timezone, schedule.shiftFor(todayIn(resolution.timezone)));
|
|
2627
|
+
return { timezone: resolution.timezone, businessDate, schedule, resolution };
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* One punch, and the day rebuilt straight afterwards.
|
|
2631
|
+
*
|
|
2632
|
+
* Recomputing inline rather than in a background job: the clock widget shows a total, and a person
|
|
2633
|
+
* who clocks out wants to see it immediately. The day sheet is a projection, so doing it twice
|
|
2634
|
+
* costs nothing.
|
|
2635
|
+
*/
|
|
2636
|
+
async function punch(input, context, direction) {
|
|
2637
|
+
const row = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2638
|
+
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
2639
|
+
const { timezone, businessDate, schedule, resolution } = await clockContext(tx, input.workspaceId, personId);
|
|
2640
|
+
// Refuse the transitions that make no sense, with a sentence rather than a constraint error:
|
|
2641
|
+
// clocking in twice, or out when never in, is somebody double-tapping a button.
|
|
2642
|
+
const existing = await attendance.punchesOn(tx, input.workspaceId, personId, businessDate);
|
|
2643
|
+
const open = openState(existing);
|
|
2644
|
+
if (direction === 'in' && open.clockedIn)
|
|
2645
|
+
throw KernError.conflict('You are already clocked in.');
|
|
2646
|
+
if (direction === 'out' && !open.clockedIn)
|
|
2647
|
+
throw KernError.conflict('You are not clocked in.');
|
|
2648
|
+
if (direction === 'break_start' && !open.clockedIn)
|
|
2649
|
+
throw KernError.conflict('Clock in before starting a break.');
|
|
2650
|
+
if (direction === 'break_start' && open.onBreak)
|
|
2651
|
+
throw KernError.conflict('You are already on a break.');
|
|
2652
|
+
if (direction === 'break_end' && !open.onBreak)
|
|
2653
|
+
throw KernError.conflict('You are not on a break.');
|
|
2654
|
+
const punchRow = await attendance.record(tx, input.workspaceId, {
|
|
2655
|
+
personId,
|
|
2656
|
+
direction,
|
|
2657
|
+
timezone,
|
|
2658
|
+
method: input.method ?? 'web',
|
|
2659
|
+
clientReportedAt: input.clientReportedAt ?? null,
|
|
2660
|
+
officeId: resolution.primaryOfficeId,
|
|
2661
|
+
geo: input.geo ?? null,
|
|
2662
|
+
note: input.note ?? null,
|
|
2663
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
2664
|
+
}, schedule);
|
|
2665
|
+
await attendance.recomputeDay(tx, input.workspaceId, personId, punchRow.businessDate, timezone, schedule);
|
|
2666
|
+
return punchRow;
|
|
2667
|
+
});
|
|
2668
|
+
await kernel.emit(hrEvents.punchRecorded, {
|
|
2669
|
+
punchId: row.id,
|
|
2670
|
+
workspaceId: input.workspaceId,
|
|
2671
|
+
personId: row.personId,
|
|
2672
|
+
direction: row.direction,
|
|
2673
|
+
businessDate: row.businessDate,
|
|
2674
|
+
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
2675
|
+
await changed(input.workspaceId, 'attendance_day', row.personId, 'updated');
|
|
2676
|
+
return toPunch(row);
|
|
2677
|
+
}
|
|
2678
|
+
/** Whether a person is currently clocked in or on a break, from their punches so far. */
|
|
2679
|
+
function openState(rows) {
|
|
2680
|
+
let clockedIn = false;
|
|
2681
|
+
let onBreak = false;
|
|
2682
|
+
for (const r of rows) {
|
|
2683
|
+
if (r.direction === 'in')
|
|
2684
|
+
clockedIn = true;
|
|
2685
|
+
else if (r.direction === 'out') {
|
|
2686
|
+
clockedIn = false;
|
|
2687
|
+
onBreak = false;
|
|
2688
|
+
}
|
|
2689
|
+
else if (r.direction === 'break_start')
|
|
2690
|
+
onBreak = true;
|
|
2691
|
+
else if (r.direction === 'break_end')
|
|
2692
|
+
onBreak = false;
|
|
2693
|
+
}
|
|
2694
|
+
return { clockedIn, onBreak };
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Apply an approved correction: write the proposed punches, void what they replace, rebuild.
|
|
2698
|
+
*
|
|
2699
|
+
* Nothing is edited. The original punch keeps its row and gains a pointer to what superseded it,
|
|
2700
|
+
* so a corrected timesheet and an edited one stay distinguishable — which is the entire reason
|
|
2701
|
+
* regularization exists rather than an update statement.
|
|
2702
|
+
*/
|
|
2703
|
+
async function applyRegularization(tx, workspaceId, regularizationId) {
|
|
2704
|
+
const [row] = await tx
|
|
2705
|
+
.select()
|
|
2706
|
+
.from(regularizations)
|
|
2707
|
+
.where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.id, regularizationId)))
|
|
2708
|
+
.limit(1);
|
|
2709
|
+
if (!row || row.status === 'approved')
|
|
2710
|
+
return;
|
|
2711
|
+
if (row.punchId)
|
|
2712
|
+
await attendance.voidPunch(tx, workspaceId, row.punchId, 'Regularized', null);
|
|
2713
|
+
const { timezone, schedule } = await clockContext(tx, workspaceId, row.personId);
|
|
2714
|
+
for (const proposal of row.proposed)
|
|
2715
|
+
await tx.insert(punches).values({
|
|
2716
|
+
id: uuidv7(),
|
|
2717
|
+
workspaceId,
|
|
2718
|
+
personId: row.personId,
|
|
2719
|
+
direction: proposal.direction,
|
|
2720
|
+
at: new Date(proposal.at),
|
|
2721
|
+
businessDate: row.businessDate,
|
|
2722
|
+
timezone,
|
|
2723
|
+
method: 'manual',
|
|
2724
|
+
trust: 'trusted',
|
|
2725
|
+
note: `Regularization ${row.id}`,
|
|
2726
|
+
});
|
|
2727
|
+
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
|
|
2728
|
+
await tx
|
|
2729
|
+
.update(regularizations)
|
|
2730
|
+
.set({ status: 'approved', appliedAt: new Date() })
|
|
2731
|
+
.where(eq(regularizations.id, row.id));
|
|
2732
|
+
}
|
|
1498
2733
|
async function emitCalendarChanged(workspaceId, calendarId, from, to, actorId) {
|
|
1499
2734
|
await kernel.emit(hrEvents.calendarChanged, { calendarId, workspaceId, from, to }, { workspaceId, actorId });
|
|
1500
2735
|
await changed(workspaceId, 'calendar', calendarId, 'updated');
|
|
@@ -1534,4 +2769,72 @@ const toResolvedDay = (r, fromCalendarId, fromCalendarName, overrides) => ({
|
|
|
1534
2769
|
fromCalendarName,
|
|
1535
2770
|
overrides,
|
|
1536
2771
|
});
|
|
2772
|
+
const toLeaveType = (r) => ({
|
|
2773
|
+
...r,
|
|
2774
|
+
unit: r.unit,
|
|
2775
|
+
archivedAt: r.archivedAt?.toISOString() ?? null,
|
|
2776
|
+
});
|
|
2777
|
+
const toLedgerEntry = (r) => ({
|
|
2778
|
+
...r,
|
|
2779
|
+
kind: r.kind,
|
|
2780
|
+
createdAt: r.createdAt.toISOString(),
|
|
2781
|
+
});
|
|
2782
|
+
const toLeaveRequest = (r) => ({
|
|
2783
|
+
...r,
|
|
2784
|
+
startPart: r.startPart,
|
|
2785
|
+
endPart: r.endPart,
|
|
2786
|
+
status: r.status,
|
|
2787
|
+
hours: r.hours === null ? null : Number.parseFloat(r.hours),
|
|
2788
|
+
workingDays: Number.parseFloat(r.workingDays),
|
|
2789
|
+
decidedAt: r.decidedAt?.toISOString() ?? null,
|
|
2790
|
+
createdAt: r.createdAt.toISOString(),
|
|
2791
|
+
updatedAt: r.updatedAt.toISOString(),
|
|
2792
|
+
});
|
|
2793
|
+
const toChain = (r) => ({
|
|
2794
|
+
...r,
|
|
2795
|
+
subjectType: r.subjectType,
|
|
2796
|
+
spec: r.spec,
|
|
2797
|
+
archivedAt: r.archivedAt?.toISOString() ?? null,
|
|
2798
|
+
});
|
|
2799
|
+
const toDelegation = (r) => ({
|
|
2800
|
+
...r,
|
|
2801
|
+
subjectType: (r.subjectType ?? null),
|
|
2802
|
+
createdAt: r.createdAt.toISOString(),
|
|
2803
|
+
});
|
|
2804
|
+
const toPunch = (r) => ({
|
|
2805
|
+
...r,
|
|
2806
|
+
direction: r.direction,
|
|
2807
|
+
method: r.method,
|
|
2808
|
+
trust: r.trust,
|
|
2809
|
+
geo: r.geo ?? null,
|
|
2810
|
+
at: r.at.toISOString(),
|
|
2811
|
+
clientReportedAt: r.clientReportedAt?.toISOString() ?? null,
|
|
2812
|
+
createdAt: r.createdAt.toISOString(),
|
|
2813
|
+
});
|
|
2814
|
+
const toAttendanceDay = (r) => ({
|
|
2815
|
+
...r,
|
|
2816
|
+
status: r.status,
|
|
2817
|
+
firstIn: r.firstIn?.toISOString() ?? null,
|
|
2818
|
+
lastOut: r.lastOut?.toISOString() ?? null,
|
|
2819
|
+
computedAt: r.computedAt.toISOString(),
|
|
2820
|
+
});
|
|
2821
|
+
const toSchedule = (r) => ({
|
|
2822
|
+
...r,
|
|
2823
|
+
kind: r.kind,
|
|
2824
|
+
week: r.week,
|
|
2825
|
+
tzMode: r.tzMode,
|
|
2826
|
+
roundingDirection: r.roundingDirection,
|
|
2827
|
+
archivedAt: r.archivedAt?.toISOString() ?? null,
|
|
2828
|
+
});
|
|
2829
|
+
const toScheduleAssignment = (r) => ({
|
|
2830
|
+
...r,
|
|
2831
|
+
createdAt: r.createdAt.toISOString(),
|
|
2832
|
+
});
|
|
2833
|
+
const toRegularization = (r) => ({
|
|
2834
|
+
...r,
|
|
2835
|
+
status: r.status,
|
|
2836
|
+
proposed: r.proposed,
|
|
2837
|
+
appliedAt: r.appliedAt?.toISOString() ?? null,
|
|
2838
|
+
createdAt: r.createdAt.toISOString(),
|
|
2839
|
+
});
|
|
1537
2840
|
//# sourceMappingURL=router.js.map
|