iobroker.time-tracker 0.2.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/LICENSE +21 -0
- package/README.md +386 -0
- package/admin/i18n/de.json +34 -0
- package/admin/i18n/en.json +1 -0
- package/admin/i18n/es.json +34 -0
- package/admin/i18n/fr.json +34 -0
- package/admin/i18n/it.json +34 -0
- package/admin/i18n/nl.json +34 -0
- package/admin/i18n/pl.json +34 -0
- package/admin/i18n/pt.json +34 -0
- package/admin/i18n/ru.json +34 -0
- package/admin/i18n/uk.json +34 -0
- package/admin/i18n/zh-cn.json +34 -0
- package/admin/jsonConfig.json +266 -0
- package/admin/time-tracker.png +0 -0
- package/build/lib/adapter/automation.js +100 -0
- package/build/lib/adapter/automation.js.map +7 -0
- package/build/lib/adapter/commands.js +160 -0
- package/build/lib/adapter/commands.js.map +7 -0
- package/build/lib/adapter/messages.js +207 -0
- package/build/lib/adapter/messages.js.map +7 -0
- package/build/lib/adapter/presence.js +129 -0
- package/build/lib/adapter/presence.js.map +7 -0
- package/build/lib/adapter/states.js +330 -0
- package/build/lib/adapter/states.js.map +7 -0
- package/build/lib/adapter/triggers.js +92 -0
- package/build/lib/adapter/triggers.js.map +7 -0
- package/build/lib/db/database.js +96 -0
- package/build/lib/db/database.js.map +7 -0
- package/build/lib/db/migrations.js +603 -0
- package/build/lib/db/migrations.js.map +7 -0
- package/build/lib/db/repositories/absences.js +432 -0
- package/build/lib/db/repositories/absences.js.map +7 -0
- package/build/lib/db/repositories/audit.js +90 -0
- package/build/lib/db/repositories/audit.js.map +7 -0
- package/build/lib/db/repositories/automations.js +326 -0
- package/build/lib/db/repositories/automations.js.map +7 -0
- package/build/lib/db/repositories/entries.js +335 -0
- package/build/lib/db/repositories/entries.js.map +7 -0
- package/build/lib/db/repositories/holidays.js +156 -0
- package/build/lib/db/repositories/holidays.js.map +7 -0
- package/build/lib/db/repositories/payouts.js +221 -0
- package/build/lib/db/repositories/payouts.js.map +7 -0
- package/build/lib/db/repositories/rfid.js +264 -0
- package/build/lib/db/repositories/rfid.js.map +7 -0
- package/build/lib/db/repositories/rules.js +356 -0
- package/build/lib/db/repositories/rules.js.map +7 -0
- package/build/lib/db/repositories/settings.js +91 -0
- package/build/lib/db/repositories/settings.js.map +7 -0
- package/build/lib/db/repositories/terminals.js +243 -0
- package/build/lib/db/repositories/terminals.js.map +7 -0
- package/build/lib/db/repositories/triggers.js +240 -0
- package/build/lib/db/repositories/triggers.js.map +7 -0
- package/build/lib/db/repositories/users.js +491 -0
- package/build/lib/db/repositories/users.js.map +7 -0
- package/build/lib/db/seed.js +180 -0
- package/build/lib/db/seed.js.map +7 -0
- package/build/lib/domain/avatar.js +63 -0
- package/build/lib/domain/avatar.js.map +7 -0
- package/build/lib/domain/breaks.js +70 -0
- package/build/lib/domain/breaks.js.map +7 -0
- package/build/lib/domain/calculation.js +150 -0
- package/build/lib/domain/calculation.js.map +7 -0
- package/build/lib/domain/holidays.js +113 -0
- package/build/lib/domain/holidays.js.map +7 -0
- package/build/lib/domain/punch.js +99 -0
- package/build/lib/domain/punch.js.map +7 -0
- package/build/lib/domain/target.js +89 -0
- package/build/lib/domain/target.js.map +7 -0
- package/build/lib/errors.js +56 -0
- package/build/lib/errors.js.map +7 -0
- package/build/lib/reports/labels.js +358 -0
- package/build/lib/reports/labels.js.map +7 -0
- package/build/lib/reports/pdf.js +308 -0
- package/build/lib/reports/pdf.js.map +7 -0
- package/build/lib/reports/types.js +32 -0
- package/build/lib/reports/types.js.map +7 -0
- package/build/lib/reports/xls.js +211 -0
- package/build/lib/reports/xls.js.map +7 -0
- package/build/lib/services/aggregation.js +495 -0
- package/build/lib/services/aggregation.js.map +7 -0
- package/build/lib/services/auth.js +381 -0
- package/build/lib/services/auth.js.map +7 -0
- package/build/lib/services/backup.js +372 -0
- package/build/lib/services/backup.js.map +7 -0
- package/build/lib/services/closing.js +106 -0
- package/build/lib/services/closing.js.map +7 -0
- package/build/lib/services/sessionSecret.js +100 -0
- package/build/lib/services/sessionSecret.js.map +7 -0
- package/build/lib/services/sync.js +278 -0
- package/build/lib/services/sync.js.map +7 -0
- package/build/lib/util/time.js +125 -0
- package/build/lib/util/time.js.map +7 -0
- package/build/lib/web/api.js +2589 -0
- package/build/lib/web/api.js.map +7 -0
- package/build/lib/web/cookies.js +80 -0
- package/build/lib/web/cookies.js.map +7 -0
- package/build/lib/web/events.js +50 -0
- package/build/lib/web/events.js.map +7 -0
- package/build/lib/web/pin-guard.js +86 -0
- package/build/lib/web/pin-guard.js.map +7 -0
- package/build/lib/web/problem.js +129 -0
- package/build/lib/web/problem.js.map +7 -0
- package/build/lib/web/rate-limit.js +65 -0
- package/build/lib/web/rate-limit.js.map +7 -0
- package/build/lib/web/router.js +274 -0
- package/build/lib/web/router.js.map +7 -0
- package/build/lib/web/server.js +189 -0
- package/build/lib/web/server.js.map +7 -0
- package/build/lib/web/static.js +151 -0
- package/build/lib/web/static.js.map +7 -0
- package/build/lib/web/stream.js +135 -0
- package/build/lib/web/stream.js.map +7 -0
- package/build/main.js +986 -0
- package/build/main.js.map +7 -0
- package/io-package.json +221 -0
- package/package.json +131 -0
- package/src-pwa/src/i18n/de.json +359 -0
- package/src-pwa/src/i18n/en.json +359 -0
- package/src-pwa/src/i18n/es.json +359 -0
- package/src-pwa/src/i18n/fr.json +359 -0
- package/src-pwa/src/i18n/index.ts +96 -0
- package/src-pwa/src/i18n/it.json +359 -0
- package/src-pwa/src/i18n/nl.json +359 -0
- package/src-pwa/src/i18n/pl.json +359 -0
- package/src-pwa/src/i18n/pt.json +359 -0
- package/src-pwa/src/i18n/ru.json +359 -0
- package/src-pwa/src/i18n/uk.json +359 -0
- package/src-pwa/src/i18n/zh-cn.json +359 -0
- package/www/assets/index-QI3wrEvW.js +119 -0
- package/www/assets/index-QI3wrEvW.js.map +1 -0
- package/www/assets/workbox-window.prod.es5-Bd17z0YL.js +3 -0
- package/www/assets/workbox-window.prod.es5-Bd17z0YL.js.map +1 -0
- package/www/favicon.svg +16 -0
- package/www/icon-192.png +0 -0
- package/www/icon-512.png +0 -0
- package/www/index.html +38 -0
- package/www/manifest.webmanifest +1 -0
- package/www/person.png +0 -0
- package/www/sw.js +3 -0
- package/www/sw.js.map +1 -0
- package/www/workbox-9c191d2f.js +3 -0
- package/www/workbox-9c191d2f.js.map +1 -0
package/build/main.js
ADDED
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
var import_node_crypto = require("node:crypto");
|
|
25
|
+
var fs = __toESM(require("node:fs"));
|
|
26
|
+
var path = __toESM(require("node:path"));
|
|
27
|
+
var utils = __toESM(require("@iobroker/adapter-core"));
|
|
28
|
+
var import_database = require("./lib/db/database");
|
|
29
|
+
var import_seed = require("./lib/db/seed");
|
|
30
|
+
var import_absences = require("./lib/db/repositories/absences");
|
|
31
|
+
var import_entries = require("./lib/db/repositories/entries");
|
|
32
|
+
var import_holidays = require("./lib/db/repositories/holidays");
|
|
33
|
+
var import_payouts = require("./lib/db/repositories/payouts");
|
|
34
|
+
var import_rules = require("./lib/db/repositories/rules");
|
|
35
|
+
var import_settings = require("./lib/db/repositories/settings");
|
|
36
|
+
var import_users = require("./lib/db/repositories/users");
|
|
37
|
+
var import_automations = require("./lib/db/repositories/automations");
|
|
38
|
+
var import_terminals = require("./lib/db/repositories/terminals");
|
|
39
|
+
var import_rfid = require("./lib/db/repositories/rfid");
|
|
40
|
+
var import_triggers = require("./lib/db/repositories/triggers");
|
|
41
|
+
var import_aggregation = require("./lib/services/aggregation");
|
|
42
|
+
var import_auth = require("./lib/services/auth");
|
|
43
|
+
var import_sessionSecret = require("./lib/services/sessionSecret");
|
|
44
|
+
var import_closing = require("./lib/services/closing");
|
|
45
|
+
var import_backup = require("./lib/services/backup");
|
|
46
|
+
var import_sync = require("./lib/services/sync");
|
|
47
|
+
var import_states = require("./lib/adapter/states");
|
|
48
|
+
var import_presence = require("./lib/adapter/presence");
|
|
49
|
+
var import_commands = require("./lib/adapter/commands");
|
|
50
|
+
var import_triggers2 = require("./lib/adapter/triggers");
|
|
51
|
+
var import_automation = require("./lib/adapter/automation");
|
|
52
|
+
var import_messages = require("./lib/adapter/messages");
|
|
53
|
+
var import_api = require("./lib/web/api");
|
|
54
|
+
var import_server = require("./lib/web/server");
|
|
55
|
+
var import_time = require("./lib/util/time");
|
|
56
|
+
var import_static = require("./lib/web/static");
|
|
57
|
+
const SUPPORTED_COUNTRIES = ["CH", "DE", "AT"];
|
|
58
|
+
const SESSION_PURGE_MINUTES = 30;
|
|
59
|
+
const STATE_REFRESH_MINUTES = 5;
|
|
60
|
+
const BACKUP_MAX_AGE_HOURS = 20;
|
|
61
|
+
const AUTOMATION_CHECK_MINUTES = 1;
|
|
62
|
+
const TAG_SECRET_FILE_NAME = "tag-secret";
|
|
63
|
+
const FIGURES_EVENT_TYPES = ["punch", "terminal.punch", "rfid.scan", "entry.update", "entry.delete", "absence.change"];
|
|
64
|
+
class TimeTracker extends utils.Adapter {
|
|
65
|
+
db = null;
|
|
66
|
+
webServer = null;
|
|
67
|
+
services = null;
|
|
68
|
+
/** Bus of the API; `null` until the API is created */
|
|
69
|
+
events = null;
|
|
70
|
+
/** Last value seen per watched trigger state: only a change fires a rule */
|
|
71
|
+
triggerValues = /* @__PURE__ */ new Map();
|
|
72
|
+
/** Trigger states the adapter currently subscribes to */
|
|
73
|
+
triggerStates = /* @__PURE__ */ new Set();
|
|
74
|
+
/** Pending refresh of the figures after a change through the API */
|
|
75
|
+
stateRefreshTimer = void 0;
|
|
76
|
+
constructor(options = {}) {
|
|
77
|
+
super({
|
|
78
|
+
...options,
|
|
79
|
+
name: "time-tracker"
|
|
80
|
+
});
|
|
81
|
+
this.on("ready", this.onReady.bind(this));
|
|
82
|
+
this.on("stateChange", this.onStateChange.bind(this));
|
|
83
|
+
this.on("message", this.onMessage.bind(this));
|
|
84
|
+
this.on("unload", this.onUnload.bind(this));
|
|
85
|
+
}
|
|
86
|
+
/** Database file: configured path or `<adapter instance data dir>/time-tracker.sqlite`. */
|
|
87
|
+
databaseFile() {
|
|
88
|
+
var _a;
|
|
89
|
+
const configured = ((_a = this.config.dbPath) != null ? _a : "").trim();
|
|
90
|
+
if (configured) {
|
|
91
|
+
return path.resolve(configured);
|
|
92
|
+
}
|
|
93
|
+
const dir = utils.getAbsoluteInstanceDataDir(this);
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
return path.join(dir, "time-tracker.sqlite");
|
|
96
|
+
}
|
|
97
|
+
/** Configured holiday country, falling back to Switzerland. */
|
|
98
|
+
holidayCountry() {
|
|
99
|
+
var _a;
|
|
100
|
+
const configured = ((_a = this.config.holidayCountry) != null ? _a : "").toUpperCase();
|
|
101
|
+
return SUPPORTED_COUNTRIES.includes(configured) ? configured : "CH";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Is called when databases are connected and adapter received configuration.
|
|
105
|
+
*/
|
|
106
|
+
async onReady() {
|
|
107
|
+
var _a;
|
|
108
|
+
try {
|
|
109
|
+
await this.setState("info.connection", false, true);
|
|
110
|
+
const file = this.databaseFile();
|
|
111
|
+
let applied = null;
|
|
112
|
+
try {
|
|
113
|
+
applied = (0, import_backup.applyPendingRestore)(file);
|
|
114
|
+
if (applied) {
|
|
115
|
+
this.log.info(
|
|
116
|
+
`restored the database from ${applied.restored.name}: ${applied.restored.users} users, ${applied.restored.entries} punches, previous file ${(_a = applied.previous) != null ? _a : "(none)"}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
this.log.error(`the queued restore was refused: ${error.message}`);
|
|
121
|
+
}
|
|
122
|
+
this.db = (0, import_database.openAndMigrate)(file, (message) => this.log.debug(message));
|
|
123
|
+
if (applied) {
|
|
124
|
+
(0, import_backup.recordRestore)(this.db, applied.restored, applied.actorId);
|
|
125
|
+
}
|
|
126
|
+
const year = (/* @__PURE__ */ new Date()).getUTCFullYear();
|
|
127
|
+
const country = this.holidayCountry();
|
|
128
|
+
const result = (0, import_seed.seed)(this.db, { holidayCountry: country, holidayYears: [year, year + 1] });
|
|
129
|
+
this.log.info(
|
|
130
|
+
`database ready at ${file} (${result.permissions} permissions, ${result.holidays} holidays added for ${country})`
|
|
131
|
+
);
|
|
132
|
+
this.ensureAdministrator(this.db);
|
|
133
|
+
this.applyConfiguration((0, import_settings.createSettingsRepository)(this.db));
|
|
134
|
+
await this.startApi();
|
|
135
|
+
await this.subscribeCommands();
|
|
136
|
+
await this.refreshTriggerSubscriptions();
|
|
137
|
+
this.runAutomationRules();
|
|
138
|
+
this.setInterval(() => this.runAutomationRules(), AUTOMATION_CHECK_MINUTES * 60 * 1e3);
|
|
139
|
+
await this.publishInstanceInfo();
|
|
140
|
+
await this.refreshStates();
|
|
141
|
+
this.setInterval(
|
|
142
|
+
() => {
|
|
143
|
+
void this.publishInstanceInfo();
|
|
144
|
+
void this.refreshStates();
|
|
145
|
+
},
|
|
146
|
+
STATE_REFRESH_MINUTES * 60 * 1e3
|
|
147
|
+
);
|
|
148
|
+
await this.runScheduledBackup();
|
|
149
|
+
this.setInterval(() => void this.runScheduledBackup(), 60 * 60 * 1e3);
|
|
150
|
+
await this.setState("info.connection", true, true);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const message = error.message;
|
|
153
|
+
this.log.error(`Startup failed: ${message}`);
|
|
154
|
+
await this.setState("info.lastError", message, true);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Creates the first administrator when the instance has none.
|
|
159
|
+
*
|
|
160
|
+
* The account is created with `must_change_pw = 1`, so the start password opens the door exactly once.
|
|
161
|
+
* Login and password can be configured; without a configured password a random one is generated and written
|
|
162
|
+
* to the log, because there is no other way to hand it to the operator.
|
|
163
|
+
*
|
|
164
|
+
* @param db - open database handle
|
|
165
|
+
*/
|
|
166
|
+
ensureAdministrator(db) {
|
|
167
|
+
var _a, _b;
|
|
168
|
+
const users = (0, import_users.createUsersRepository)(db);
|
|
169
|
+
const administrators = users.list().filter((user) => users.roles(user.id).includes("admin"));
|
|
170
|
+
if (administrators.length > 0) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const login = ((_a = this.config.adminLogin) != null ? _a : "").trim() || "admin";
|
|
174
|
+
const configured = ((_b = this.config.adminPassword) != null ? _b : "").trim();
|
|
175
|
+
const password = configured || `Zf-${(0, import_node_crypto.randomBytes)(9).toString("base64url")}-7`;
|
|
176
|
+
try {
|
|
177
|
+
users.create({
|
|
178
|
+
login,
|
|
179
|
+
displayName: login,
|
|
180
|
+
passwordHash: (0, import_auth.hashPassword)(password),
|
|
181
|
+
mustChangePw: true,
|
|
182
|
+
timezone: this.config.timezone || "Europe/Berlin",
|
|
183
|
+
roleKeys: ["admin"],
|
|
184
|
+
now: Math.floor(Date.now() / 1e3)
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.log.warn(`the administrator "${login}" could not be created: ${error.message}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (configured) {
|
|
191
|
+
this.log.info(
|
|
192
|
+
`administrator "${login}" created with the configured start password - it has to be changed at the first login`
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
this.log.warn(
|
|
196
|
+
`administrator "${login}" created with the start password "${password}" - change it at the first login`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Copies the instance configuration into the stored settings and publishes the instance information.
|
|
202
|
+
*
|
|
203
|
+
* The ioBroker admin UI is where an operator configures the instance, so a configured value wins over what
|
|
204
|
+
* is stored in the database; `PUT /api/settings` stays for the keys the admin UI does not offer. Empty
|
|
205
|
+
* fields are ignored, so clearing a field never wipes a stored setting.
|
|
206
|
+
*
|
|
207
|
+
* @param settings - instance settings
|
|
208
|
+
*/
|
|
209
|
+
applyConfiguration(settings) {
|
|
210
|
+
var _a, _b;
|
|
211
|
+
const text = (value) => (value == null ? void 0 : value.trim()) ? value.trim() : null;
|
|
212
|
+
const number = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : null;
|
|
213
|
+
const flag = (value) => value === void 0 ? null : value ? "1" : "0";
|
|
214
|
+
const mapped = [
|
|
215
|
+
["holiday_country", (_b = (_a = text(this.config.holidayCountry)) == null ? void 0 : _a.toUpperCase()) != null ? _b : null],
|
|
216
|
+
["timezone", text(this.config.timezone)],
|
|
217
|
+
["default_language", text(this.config.defaultLanguage)],
|
|
218
|
+
["edit_window_days", number(this.config.editWindowDays)],
|
|
219
|
+
["quick_round_minutes", number(this.config.quickRoundMinutes)],
|
|
220
|
+
["session_ttl_minutes", number(this.config.sessionTtlMinutes)],
|
|
221
|
+
["backup_retention_days", number(this.config.backupRetentionDays)],
|
|
222
|
+
["absence_calc_until_today", flag(this.config.absenceCalcUntilToday)],
|
|
223
|
+
["absence_deduct_worktime", flag(this.config.absenceDeductWorktime)]
|
|
224
|
+
];
|
|
225
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
226
|
+
for (const [key, value] of mapped) {
|
|
227
|
+
if (value !== null && settings.get(key) !== value) {
|
|
228
|
+
settings.set(key, value, null, now);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Publishes version, database size and schema version as states (specification 5.1).
|
|
234
|
+
*
|
|
235
|
+
* The objects are created by `createInfoStates`, so this runs after the command states were set up.
|
|
236
|
+
*/
|
|
237
|
+
async publishInstanceInfo() {
|
|
238
|
+
var _a;
|
|
239
|
+
await this.setState("info.version", (_a = this.version) != null ? _a : "0.0.0", true);
|
|
240
|
+
const file = this.db ? this.databaseFile() : "";
|
|
241
|
+
await this.setState("info.dbSizeBytes", file && fs.existsSync(file) ? fs.statSync(file).size : 0, true);
|
|
242
|
+
if (this.db) {
|
|
243
|
+
await this.setState("info.schemaVersion", String((0, import_database.currentSchemaVersion)(this.db)), true);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Secret used to sign CSRF tokens. A value from the instance settings wins; without one the adapter generates a
|
|
248
|
+
* secret on the first start and stores it next to the database, so a restart keeps the CSRF tokens of clients
|
|
249
|
+
* that are already open valid.
|
|
250
|
+
*/
|
|
251
|
+
sessionSecret() {
|
|
252
|
+
var _a;
|
|
253
|
+
const resolved = (0, import_sessionSecret.resolveSessionSecret)({
|
|
254
|
+
configured: this.config.sessionSecret,
|
|
255
|
+
file: path.join(path.dirname(this.databaseFile()), import_sessionSecret.SECRET_FILE_NAME)
|
|
256
|
+
});
|
|
257
|
+
if (resolved.source === "configured") {
|
|
258
|
+
this.log.debug("session secret taken from the instance settings");
|
|
259
|
+
} else if (resolved.source === "stored") {
|
|
260
|
+
this.log.debug(`session secret taken from ${resolved.file}`);
|
|
261
|
+
} else if (resolved.file) {
|
|
262
|
+
this.log.info(
|
|
263
|
+
`no session secret configured - generated one and stored it at ${resolved.file}, so sessions and CSRF tokens survive restarts`
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
this.log.warn(
|
|
267
|
+
`no session secret configured and it could not be stored (${(_a = resolved.error) != null ? _a : "unknown"}) - a temporary one is used, so all sessions end with the next restart`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return resolved.secret;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Secret that signs the badge/NFC links.
|
|
274
|
+
*
|
|
275
|
+
* A value from the instance settings wins. Without one a secret is generated on the first start and stored next
|
|
276
|
+
* to the database — otherwise a fresh installation could not create a badge at all and the administration would
|
|
277
|
+
* only report “not configured”.
|
|
278
|
+
*/
|
|
279
|
+
tagSecret() {
|
|
280
|
+
var _a;
|
|
281
|
+
const resolved = (0, import_sessionSecret.resolveSessionSecret)({
|
|
282
|
+
configured: this.config.hmacSecret,
|
|
283
|
+
file: path.join(path.dirname(this.databaseFile()), TAG_SECRET_FILE_NAME)
|
|
284
|
+
});
|
|
285
|
+
if (resolved.source === "configured") {
|
|
286
|
+
this.log.debug("badge link secret taken from the instance settings");
|
|
287
|
+
} else if (resolved.source === "stored") {
|
|
288
|
+
this.log.debug(`badge link secret taken from ${resolved.file}`);
|
|
289
|
+
} else if (resolved.file) {
|
|
290
|
+
this.log.info(
|
|
291
|
+
`no badge link secret configured - generated one and stored it at ${resolved.file}, so badges stay valid across restarts`
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
this.log.warn(
|
|
295
|
+
`no badge link secret configured and it could not be stored (${(_a = resolved.error) != null ? _a : "unknown"}) - a temporary one is used, so badges stop working with the next restart`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return resolved.secret;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Builds the services and starts the HTTP server on the configured port.
|
|
302
|
+
*
|
|
303
|
+
* Binding can fail (port in use); that must never stop the adapter, so the failure is only logged.
|
|
304
|
+
*/
|
|
305
|
+
async startApi() {
|
|
306
|
+
var _a;
|
|
307
|
+
const db = this.db;
|
|
308
|
+
if (!db) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const users = (0, import_users.createUsersRepository)(db);
|
|
312
|
+
const entries = (0, import_entries.createEntriesRepository)(db);
|
|
313
|
+
const absences = (0, import_absences.createAbsencesRepository)(db);
|
|
314
|
+
const holidays = (0, import_holidays.createHolidaysRepository)(db);
|
|
315
|
+
const rules = (0, import_rules.createRulesRepository)(db);
|
|
316
|
+
const payouts = (0, import_payouts.createPayoutsRepository)(db);
|
|
317
|
+
const terminals = (0, import_terminals.createTerminalsRepository)(db);
|
|
318
|
+
const rfid = (0, import_rfid.createRfidRepository)(db);
|
|
319
|
+
const settings = (0, import_settings.createSettingsRepository)(db);
|
|
320
|
+
const auth = (0, import_auth.createAuthService)({
|
|
321
|
+
db,
|
|
322
|
+
users,
|
|
323
|
+
settings,
|
|
324
|
+
secret: this.sessionSecret(),
|
|
325
|
+
defaultTtlMinutes: this.config.sessionTtlMinutes || 720
|
|
326
|
+
});
|
|
327
|
+
const aggregation = (0, import_aggregation.createAggregationService)({
|
|
328
|
+
db,
|
|
329
|
+
users,
|
|
330
|
+
entries,
|
|
331
|
+
absences,
|
|
332
|
+
holidays,
|
|
333
|
+
rules,
|
|
334
|
+
settings
|
|
335
|
+
});
|
|
336
|
+
const triggers = (0, import_triggers.createTriggersRepository)(db);
|
|
337
|
+
const automations = (0, import_automations.createAutomationsRepository)(db);
|
|
338
|
+
const sync = (0, import_sync.createSyncService)({ db, entries, users, aggregation });
|
|
339
|
+
const closing = (0, import_closing.createClosingService)({ db, aggregation, payouts });
|
|
340
|
+
const backup = (0, import_backup.createBackupService)({
|
|
341
|
+
db,
|
|
342
|
+
dir: path.join(path.dirname(this.databaseFile()), "backups"),
|
|
343
|
+
retentionDays: Math.max(0, Number((_a = this.config.backupRetentionDays) != null ? _a : 30) || 0)
|
|
344
|
+
});
|
|
345
|
+
const api = (0, import_api.createApi)({
|
|
346
|
+
db,
|
|
347
|
+
auth,
|
|
348
|
+
users,
|
|
349
|
+
entries,
|
|
350
|
+
absences,
|
|
351
|
+
holidays,
|
|
352
|
+
rules,
|
|
353
|
+
payouts,
|
|
354
|
+
terminals,
|
|
355
|
+
rfid,
|
|
356
|
+
triggers,
|
|
357
|
+
automations,
|
|
358
|
+
aggregation,
|
|
359
|
+
sync,
|
|
360
|
+
settings,
|
|
361
|
+
backup,
|
|
362
|
+
kioskEnabled: this.config.kioskEnabled === true,
|
|
363
|
+
// a reverse proxy in front is the normal case for HTTPS; without the switch the forwarded
|
|
364
|
+
// headers are ignored, so a client cannot choose its own address or the HTTPS flag
|
|
365
|
+
trustProxy: this.config.trustProxy === true,
|
|
366
|
+
hmacSecret: this.tagSecret(),
|
|
367
|
+
version: this.version,
|
|
368
|
+
// a saved trigger rule changes the states the adapter has to watch
|
|
369
|
+
onTriggerRulesChanged: () => void this.refreshTriggerSubscriptions()
|
|
370
|
+
});
|
|
371
|
+
this.services = {
|
|
372
|
+
users,
|
|
373
|
+
entries,
|
|
374
|
+
absences,
|
|
375
|
+
settings,
|
|
376
|
+
aggregation,
|
|
377
|
+
sync,
|
|
378
|
+
closing,
|
|
379
|
+
backup,
|
|
380
|
+
triggers,
|
|
381
|
+
automations
|
|
382
|
+
};
|
|
383
|
+
this.events = api.events;
|
|
384
|
+
api.events.subscribe((event) => {
|
|
385
|
+
void this.publishEventState(event);
|
|
386
|
+
if (FIGURES_EVENT_TYPES.includes(event.type)) {
|
|
387
|
+
this.scheduleStateRefresh();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
this.log.debug(`API routes: ${api.routes().length}`);
|
|
391
|
+
const webDir = path.join(__dirname, "..", "www");
|
|
392
|
+
const staticFiles = fs.existsSync(path.join(webDir, "index.html")) ? (0, import_static.createStaticHandler)({ root: webDir }) : void 0;
|
|
393
|
+
this.log.info(
|
|
394
|
+
staticFiles ? `web interface found at ${webDir}` : `no web interface at ${webDir} - only the API (and the terminal) is available`
|
|
395
|
+
);
|
|
396
|
+
try {
|
|
397
|
+
this.webServer = await (0, import_server.startWebServer)({
|
|
398
|
+
router: api.router,
|
|
399
|
+
port: this.config.port || 8092,
|
|
400
|
+
bind: this.config.bind || "127.0.0.1",
|
|
401
|
+
staticFiles,
|
|
402
|
+
// an uploaded backup is bigger than the default limit; the route carries the same bound
|
|
403
|
+
maxBodyBytes: import_api.MAX_BACKUP_UPLOAD_BYTES,
|
|
404
|
+
// live events for the PWA and the terminal: `/api/stream?token=...`
|
|
405
|
+
stream: {
|
|
406
|
+
auth,
|
|
407
|
+
events: api.events,
|
|
408
|
+
version: this.version,
|
|
409
|
+
timers: {
|
|
410
|
+
// the adapter's own timer functions: they are cleared with the adapter on unload
|
|
411
|
+
setInterval: (handler, milliseconds) => this.setInterval(handler, milliseconds),
|
|
412
|
+
clearInterval: (handle) => this.clearInterval(handle)
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
log: {
|
|
416
|
+
info: (message) => this.log.info(message),
|
|
417
|
+
warn: (message) => this.log.warn(message),
|
|
418
|
+
error: (message) => this.log.error(message)
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
} catch (error) {
|
|
422
|
+
this.log.error(
|
|
423
|
+
`API could not be started on port ${this.config.port}: ${error.message} - the web interface and the terminal stay unavailable`
|
|
424
|
+
);
|
|
425
|
+
this.webServer = null;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
this.setInterval(
|
|
429
|
+
() => {
|
|
430
|
+
try {
|
|
431
|
+
const removed = auth.purge();
|
|
432
|
+
if (removed > 0) {
|
|
433
|
+
this.log.debug(`removed ${removed} expired session(s)`);
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
this.log.warn(`session cleanup failed: ${error.message}`);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
SESSION_PURGE_MINUTES * 60 * 1e3
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Creates the command states and subscribes to them.
|
|
444
|
+
*/
|
|
445
|
+
async subscribeCommands() {
|
|
446
|
+
try {
|
|
447
|
+
await (0, import_states.createCommandStates)(this);
|
|
448
|
+
await (0, import_states.createInfoStates)(this);
|
|
449
|
+
await (0, import_states.createCompanyStates)(this);
|
|
450
|
+
await (0, import_states.createEventStates)(this);
|
|
451
|
+
await this.subscribeStatesAsync("commands.*");
|
|
452
|
+
await this.subscribeStatesAsync(`users.*.${import_presence.PRESENCE_SUFFIX}`);
|
|
453
|
+
this.log.debug("command states ready");
|
|
454
|
+
} catch (error) {
|
|
455
|
+
this.log.warn(`command states could not be created: ${error.message}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Writes a backup unless a recent one exists.
|
|
460
|
+
*
|
|
461
|
+
* The check runs hourly and once at start: a nightly backup therefore happens within an hour at the latest,
|
|
462
|
+
* and an instance that was down for days does not write a burst of copies.
|
|
463
|
+
*/
|
|
464
|
+
async runScheduledBackup() {
|
|
465
|
+
var _a, _b;
|
|
466
|
+
const backup = (_a = this.services) == null ? void 0 : _a.backup;
|
|
467
|
+
if (!backup) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const newest = backup.list()[0];
|
|
472
|
+
const ageHours = newest ? (Date.now() / 1e3 - newest.createdAt) / 3600 : Number.POSITIVE_INFINITY;
|
|
473
|
+
if (ageHours < BACKUP_MAX_AGE_HOURS) {
|
|
474
|
+
this.log.debug(`backup ${(_b = newest == null ? void 0 : newest.name) != null ? _b : ""} is ${ageHours.toFixed(1)} h old, nothing to do`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const created = backup.create({ actorId: null, reason: "scheduled" });
|
|
478
|
+
this.log.info(
|
|
479
|
+
`backup ${created.backup.name} written (${created.backup.sizeBytes} bytes, ${created.backup.users} employees, ${created.backup.entries} punches), ${created.removed.length} old file(s) removed`
|
|
480
|
+
);
|
|
481
|
+
await this.announceBackup(created.backup.name, created.backup.createdAt);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
this.log.warn(`backup failed: ${error.message}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Publishes the instant of a written backup.
|
|
488
|
+
*
|
|
489
|
+
* @param name - file name of the backup
|
|
490
|
+
* @param createdAt - instant the backup belongs to
|
|
491
|
+
*/
|
|
492
|
+
async announceBackup(name, createdAt) {
|
|
493
|
+
var _a;
|
|
494
|
+
await this.setState("info.lastBackup", createdAt, true);
|
|
495
|
+
(_a = this.events) == null ? void 0 : _a.publish({
|
|
496
|
+
type: "backup.create",
|
|
497
|
+
atUtc: createdAt,
|
|
498
|
+
userId: null,
|
|
499
|
+
data: { backup: name }
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Applies a write on the presence state of an employee.
|
|
504
|
+
*
|
|
505
|
+
* `users.<id>.present` is the writable twin of `users.<id>.hasOpenEntry`: `true` opens an entry, `false` closes
|
|
506
|
+
* it. Afterwards the figures of all employees are republished, so a dashboard sees the new state right away.
|
|
507
|
+
*
|
|
508
|
+
* @param id - state id without the instance prefix
|
|
509
|
+
* @param value - value written by the user or a script
|
|
510
|
+
*/
|
|
511
|
+
async runPresence(id, value) {
|
|
512
|
+
const services = this.services;
|
|
513
|
+
if (!services) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const result = (0, import_presence.handlePresenceState)(
|
|
518
|
+
{
|
|
519
|
+
entries: services.entries,
|
|
520
|
+
users: services.users,
|
|
521
|
+
aggregation: services.aggregation
|
|
522
|
+
},
|
|
523
|
+
id,
|
|
524
|
+
value
|
|
525
|
+
);
|
|
526
|
+
if (result.ok) {
|
|
527
|
+
this.log.info(`presence ${id}: ${result.message}`);
|
|
528
|
+
} else {
|
|
529
|
+
this.log.warn(`presence ${id}: ${result.message}`);
|
|
530
|
+
}
|
|
531
|
+
await this.refreshStates();
|
|
532
|
+
} catch (error) {
|
|
533
|
+
this.log.error(`Error in runPresence for ${id}: ${error.message}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Publishes the figures of all employees.
|
|
538
|
+
*/
|
|
539
|
+
async refreshStates() {
|
|
540
|
+
const services = this.services;
|
|
541
|
+
if (!services) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
const snapshots = await (0, import_states.publishAllUserStates)({
|
|
546
|
+
port: this,
|
|
547
|
+
aggregation: services.aggregation,
|
|
548
|
+
users: services.users,
|
|
549
|
+
sync: services.sync
|
|
550
|
+
});
|
|
551
|
+
this.log.debug(`published ${snapshots.length} employee state(s)`);
|
|
552
|
+
await (0, import_states.publishCompanySnapshot)(this, (0, import_states.readCompanySnapshot)(snapshots));
|
|
553
|
+
} catch (error) {
|
|
554
|
+
this.log.warn(`states could not be published: ${error.message}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Runs a command state and reports the outcome.
|
|
559
|
+
*
|
|
560
|
+
* @param id - state id without the instance prefix
|
|
561
|
+
* @param value - value written by the user or a script
|
|
562
|
+
*/
|
|
563
|
+
async runCommand(id, value) {
|
|
564
|
+
var _a;
|
|
565
|
+
const services = this.services;
|
|
566
|
+
const db = this.db;
|
|
567
|
+
if (!services || !db) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const result = (0, import_commands.handleCommand)(
|
|
572
|
+
{
|
|
573
|
+
db,
|
|
574
|
+
entries: services.entries,
|
|
575
|
+
users: services.users,
|
|
576
|
+
settings: services.settings,
|
|
577
|
+
aggregation: services.aggregation,
|
|
578
|
+
closing: services.closing,
|
|
579
|
+
backup: services.backup
|
|
580
|
+
},
|
|
581
|
+
id,
|
|
582
|
+
value
|
|
583
|
+
);
|
|
584
|
+
this.log.info(`command ${id}: ${result.message}`);
|
|
585
|
+
if (id === import_states.COMMAND_IDS.closeMonth) {
|
|
586
|
+
(_a = this.events) == null ? void 0 : _a.publish({
|
|
587
|
+
type: "month.close",
|
|
588
|
+
atUtc: Math.floor(Date.now() / 1e3),
|
|
589
|
+
userId: null,
|
|
590
|
+
data: { period: String(value != null ? value : "") }
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
if (id === import_states.COMMAND_IDS.backup && result.ok) {
|
|
594
|
+
const newest = services.backup.list()[0];
|
|
595
|
+
if (newest) {
|
|
596
|
+
await this.announceBackup(newest.name, newest.createdAt);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} catch (error) {
|
|
600
|
+
this.log.warn(`command ${id} failed: ${error.message}`);
|
|
601
|
+
}
|
|
602
|
+
if (id === import_states.COMMAND_IDS.punch || id === import_states.COMMAND_IDS.quickPunch || id === import_states.COMMAND_IDS.backup) {
|
|
603
|
+
await this.setState(id, false, true);
|
|
604
|
+
}
|
|
605
|
+
await this.refreshStates();
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Watches exactly the states the active trigger rules name.
|
|
609
|
+
*
|
|
610
|
+
* Runs at startup and whenever the administration saves the rules, so an added, changed or removed rule takes
|
|
611
|
+
* effect without a restart. A state that no rule names any more is dropped from the watch list.
|
|
612
|
+
*/
|
|
613
|
+
async refreshTriggerSubscriptions() {
|
|
614
|
+
const services = this.services;
|
|
615
|
+
if (!services) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
let rules = [];
|
|
619
|
+
try {
|
|
620
|
+
rules = services.triggers.list();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
this.log.warn(`trigger rules could not be read: ${error.message}`);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const wanted = new Set(rules.map((rule) => rule.sourceState));
|
|
626
|
+
for (const id of [...this.triggerStates]) {
|
|
627
|
+
if (wanted.has(id)) {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
await this.unsubscribeForeignStatesAsync(id);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
this.log.debug(`trigger state ${id} could not be released: ${error.message}`);
|
|
634
|
+
}
|
|
635
|
+
this.triggerStates.delete(id);
|
|
636
|
+
this.triggerValues.delete(id);
|
|
637
|
+
}
|
|
638
|
+
for (const id of wanted) {
|
|
639
|
+
if (this.triggerStates.has(id)) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
await this.subscribeForeignStatesAsync(id);
|
|
644
|
+
this.triggerStates.add(id);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
this.log.warn(`trigger state ${id} could not be watched: ${error.message}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
this.log.debug(`watching ${this.triggerStates.size} trigger state(s) for ${rules.length} active rule(s)`);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Applies a write on a state that trigger rules watch.
|
|
653
|
+
*
|
|
654
|
+
* Such a state belongs to another adapter, so its `ack` says nothing about whether the value was set by a
|
|
655
|
+
* script or by the device. What counts is that the value **changed**: a reader that repeats itself, a
|
|
656
|
+
* dashboard that refreshes or a script that writes the same value twice does not punch twice. Together with
|
|
657
|
+
* the cooldown of a rule even a rapidly blinking state stays harmless.
|
|
658
|
+
*
|
|
659
|
+
* @param id - full state id
|
|
660
|
+
* @param state - state object of the write
|
|
661
|
+
* @returns true when the state belongs to a rule and was handled here
|
|
662
|
+
*/
|
|
663
|
+
handleTriggerState(id, state) {
|
|
664
|
+
var _a;
|
|
665
|
+
const services = this.services;
|
|
666
|
+
if (!services) {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
const previous = (_a = this.triggerValues.get(id)) != null ? _a : null;
|
|
670
|
+
this.triggerValues.set(id, (0, import_triggers2.triggerText)(state.val));
|
|
671
|
+
const rules = services.triggers.list().filter((rule) => rule.sourceState === id);
|
|
672
|
+
if (rules.length === 0) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
676
|
+
for (const rule of rules) {
|
|
677
|
+
const decision = (0, import_triggers2.evaluateTrigger)(rule, { value: state.val, previous, now }, services.users);
|
|
678
|
+
if (!decision.fire || decision.userId === null) {
|
|
679
|
+
this.log.debug(`trigger ${rule.id} (${rule.sourceState}) did not fire: ${decision.reason}`);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
this.runTrigger(rule, decision.userId);
|
|
683
|
+
}
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Runs the action of a rule that fired.
|
|
688
|
+
*
|
|
689
|
+
* The punch goes through the same path as the command state and a presence change through the same handler as
|
|
690
|
+
* the `users.<id>.present` state, so direction, rounding, notes and the recalculation behave identically.
|
|
691
|
+
*
|
|
692
|
+
* @param rule - rule that fired
|
|
693
|
+
* @param userId - employee the action applies to
|
|
694
|
+
*/
|
|
695
|
+
runTrigger(rule, userId) {
|
|
696
|
+
void (async () => {
|
|
697
|
+
const services = this.services;
|
|
698
|
+
if (!services) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
const result = rule.action === "present" || rule.action === "absent" ? (0, import_presence.handlePresenceState)(
|
|
703
|
+
{ entries: services.entries, users: services.users, aggregation: services.aggregation },
|
|
704
|
+
`users.${userId}.${import_presence.PRESENCE_SUFFIX}`,
|
|
705
|
+
rule.action === "present"
|
|
706
|
+
) : (0, import_commands.punchEmployee)(
|
|
707
|
+
{
|
|
708
|
+
entries: services.entries,
|
|
709
|
+
users: services.users,
|
|
710
|
+
settings: services.settings,
|
|
711
|
+
aggregation: services.aggregation
|
|
712
|
+
},
|
|
713
|
+
{ userId, quick: rule.action === "quickPunch", note: `trigger.${rule.id}` }
|
|
714
|
+
);
|
|
715
|
+
this.log.info(`trigger ${rule.id} (${rule.sourceState}) \u2192 ${result.message}`);
|
|
716
|
+
services.triggers.markFired({ id: rule.id });
|
|
717
|
+
await this.refreshStates();
|
|
718
|
+
} catch (error) {
|
|
719
|
+
this.log.warn(`trigger ${rule.id} failed: ${error.message}`);
|
|
720
|
+
}
|
|
721
|
+
})();
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Stops the HTTP server.
|
|
725
|
+
*/
|
|
726
|
+
async stopApi() {
|
|
727
|
+
const server = this.webServer;
|
|
728
|
+
this.webServer = null;
|
|
729
|
+
if (server) {
|
|
730
|
+
await server.close();
|
|
731
|
+
this.log.debug("API stopped");
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Is called when adapter shuts down - callback has to be called under any circumstances!
|
|
736
|
+
*
|
|
737
|
+
* @param callback - Callback function
|
|
738
|
+
*/
|
|
739
|
+
onUnload(callback) {
|
|
740
|
+
try {
|
|
741
|
+
void this.stopApi().catch((error) => this.log.warn(`stopping the API failed: ${error.message}`)).finally(() => {
|
|
742
|
+
var _a;
|
|
743
|
+
(_a = this.db) == null ? void 0 : _a.close();
|
|
744
|
+
this.db = null;
|
|
745
|
+
callback();
|
|
746
|
+
});
|
|
747
|
+
} catch (error) {
|
|
748
|
+
this.log.error(`Error during unloading: ${error.message}`);
|
|
749
|
+
callback();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Runs the automation rules that are due.
|
|
754
|
+
*
|
|
755
|
+
* A rule acts at most once per employee and period — the local date, or the ISO week for a weekly rule;
|
|
756
|
+
* `automation_runs` holds that decision, so a restart
|
|
757
|
+
* cannot punch twice. The run is noted **before** the action (a double punch would be worse than a missed
|
|
758
|
+
* reminder) and taken back when the action fails, so the next minute retries it.
|
|
759
|
+
*/
|
|
760
|
+
runAutomationRules() {
|
|
761
|
+
var _a;
|
|
762
|
+
const services = this.services;
|
|
763
|
+
if (!services) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
let rules = [];
|
|
767
|
+
try {
|
|
768
|
+
rules = services.automations.list();
|
|
769
|
+
} catch (error) {
|
|
770
|
+
this.log.warn(`automation rules could not be read: ${error.message}`);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (rules.length === 0) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const nowUtc = Math.floor(Date.now() / 1e3);
|
|
777
|
+
let acted = false;
|
|
778
|
+
for (const rule of rules) {
|
|
779
|
+
const candidates = rule.userId === null ? services.users.list() : [services.users.findById(rule.userId)].filter((user) => user !== null);
|
|
780
|
+
for (const user of candidates) {
|
|
781
|
+
if (!user.isActive) {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const local = (0, import_time.localDateTime)(nowUtc, user.timezone);
|
|
785
|
+
const period = (0, import_automation.runPeriod)(rule.repeat, local.date);
|
|
786
|
+
if (services.automations.hasRun({ ruleId: rule.id, userId: user.id, period })) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const punches = services.entries.listByDate(user.id, local.date).map((entry) => ({ tsUtc: entry.tsUtc, direction: entry.direction }));
|
|
790
|
+
const block = (0, import_automation.workBlock)(punches, nowUtc);
|
|
791
|
+
const decision = (0, import_automation.evaluateAutomation)(rule, {
|
|
792
|
+
localDate: local.date,
|
|
793
|
+
weekday: (0, import_automation.isoWeekday)(local.date),
|
|
794
|
+
minuteOfDay: local.minutes,
|
|
795
|
+
hasOpenEntry: block.hasOpenEntry,
|
|
796
|
+
blockMinutes: block.blockMinutes,
|
|
797
|
+
alreadyRan: false
|
|
798
|
+
});
|
|
799
|
+
if (!decision.fire) {
|
|
800
|
+
this.log.debug(`automation ${rule.id} (${user.displayName}): ${decision.reason}`);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const action = this.automationAction(rule, block.blockMinutes);
|
|
804
|
+
if (!services.automations.recordRun({
|
|
805
|
+
ruleId: rule.id,
|
|
806
|
+
userId: user.id,
|
|
807
|
+
period,
|
|
808
|
+
action,
|
|
809
|
+
now: nowUtc
|
|
810
|
+
})) {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
if (rule.kind === "clockOut" || rule.kind === "clockIn") {
|
|
815
|
+
const result = (0, import_commands.punchEmployee)(
|
|
816
|
+
{
|
|
817
|
+
entries: services.entries,
|
|
818
|
+
users: services.users,
|
|
819
|
+
settings: services.settings,
|
|
820
|
+
aggregation: services.aggregation
|
|
821
|
+
},
|
|
822
|
+
{ userId: user.id, note: `auto.${rule.kind}`, now: nowUtc }
|
|
823
|
+
);
|
|
824
|
+
this.log.info(`automation ${rule.id} for ${user.displayName}: ${result.message}`);
|
|
825
|
+
} else {
|
|
826
|
+
this.log.info(`automation ${rule.id} for ${user.displayName}: ${action}`);
|
|
827
|
+
}
|
|
828
|
+
acted = true;
|
|
829
|
+
(_a = this.events) == null ? void 0 : _a.publish({
|
|
830
|
+
type: `automation.${rule.kind}`,
|
|
831
|
+
atUtc: nowUtc,
|
|
832
|
+
userId: user.id,
|
|
833
|
+
data: { ruleId: rule.id, action, reason: decision.reason }
|
|
834
|
+
});
|
|
835
|
+
} catch (error) {
|
|
836
|
+
services.automations.forgetRun({ ruleId: rule.id, userId: user.id, period });
|
|
837
|
+
this.log.warn(`automation ${rule.id} for ${user.displayName} failed: ${error.message}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (acted) {
|
|
842
|
+
this.scheduleStateRefresh();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Short description of what a rule did (the log entry of `automation_runs`).
|
|
847
|
+
*
|
|
848
|
+
* @param rule - rule that fired
|
|
849
|
+
* @param blockMinutes - length of the running work block, `null` when the employee is not clocked in
|
|
850
|
+
* @returns text for the log
|
|
851
|
+
*/
|
|
852
|
+
automationAction(rule, blockMinutes) {
|
|
853
|
+
if (rule.kind === "clockOut") {
|
|
854
|
+
return "clocked out";
|
|
855
|
+
}
|
|
856
|
+
if (rule.kind === "clockIn") {
|
|
857
|
+
return "clocked in";
|
|
858
|
+
}
|
|
859
|
+
if (rule.kind === "breakReminder") {
|
|
860
|
+
return `reminded after ${blockMinutes != null ? blockMinutes : 0} min without a break`;
|
|
861
|
+
}
|
|
862
|
+
return "reported a missing punch";
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Republishes the figures shortly after data changed.
|
|
866
|
+
*
|
|
867
|
+
* A punch from the web app, the terminal or a badge arrives through the API of this very process, so without
|
|
868
|
+
* this the states would only change with the five minute timer. One action often produces several events, so
|
|
869
|
+
* the refresh is collected for a moment instead of running once per event.
|
|
870
|
+
*/
|
|
871
|
+
scheduleStateRefresh() {
|
|
872
|
+
if (this.stateRefreshTimer) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
this.stateRefreshTimer = this.setTimeout(() => {
|
|
876
|
+
this.stateRefreshTimer = void 0;
|
|
877
|
+
void this.refreshStates();
|
|
878
|
+
}, 1e3);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Publishes the newest event of the instance into the state tree.
|
|
882
|
+
*
|
|
883
|
+
* A notification adapter, a dashboard or a Blockly script then only has to watch `events.*` instead of
|
|
884
|
+
* following the live stream of the web app.
|
|
885
|
+
*
|
|
886
|
+
* @param event - event published by the API
|
|
887
|
+
*/
|
|
888
|
+
async publishEventState(event) {
|
|
889
|
+
var _a, _b;
|
|
890
|
+
const services = this.services;
|
|
891
|
+
if (!services) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
const data = (_a = event.data) != null ? _a : {};
|
|
896
|
+
const user = event.userId === null || event.userId === void 0 ? null : services.users.findById(event.userId);
|
|
897
|
+
await (0, import_states.publishEventSnapshot)(this, {
|
|
898
|
+
type: event.type,
|
|
899
|
+
atUtc: event.atUtc,
|
|
900
|
+
userName: (_b = user == null ? void 0 : user.displayName) != null ? _b : "",
|
|
901
|
+
direction: typeof data.direction === "string" ? data.direction : "",
|
|
902
|
+
source: typeof data.source === "string" ? data.source : ""
|
|
903
|
+
});
|
|
904
|
+
} catch (error) {
|
|
905
|
+
this.log.debug(`event state could not be published: ${error.message}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Is called when another adapter sends a message (`sendTo`).
|
|
910
|
+
*
|
|
911
|
+
* @param obj - message object of the adapter framework
|
|
912
|
+
*/
|
|
913
|
+
onMessage(obj) {
|
|
914
|
+
void (async () => {
|
|
915
|
+
const services = this.services;
|
|
916
|
+
if (!services) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
const answer = await (0, import_messages.handleMessage)(
|
|
921
|
+
{
|
|
922
|
+
users: services.users,
|
|
923
|
+
entries: services.entries,
|
|
924
|
+
absences: services.absences,
|
|
925
|
+
settings: services.settings,
|
|
926
|
+
aggregation: services.aggregation,
|
|
927
|
+
sync: services.sync,
|
|
928
|
+
backup: services.backup,
|
|
929
|
+
version: this.version
|
|
930
|
+
},
|
|
931
|
+
obj.command,
|
|
932
|
+
obj.message
|
|
933
|
+
);
|
|
934
|
+
this.log.info(`message ${obj.command}: ${answer.message}`);
|
|
935
|
+
if (obj.callback) {
|
|
936
|
+
this.sendTo(obj.from, obj.command, answer, obj.callback);
|
|
937
|
+
}
|
|
938
|
+
await this.refreshStates();
|
|
939
|
+
} catch (error) {
|
|
940
|
+
const message = error.message;
|
|
941
|
+
this.log.warn(`message ${obj.command} failed: ${message}`);
|
|
942
|
+
if (obj.callback) {
|
|
943
|
+
this.sendTo(obj.from, obj.command, { ok: false, message }, obj.callback);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
})();
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Is called if a subscribed state changes
|
|
950
|
+
*
|
|
951
|
+
* @param id - State ID
|
|
952
|
+
* @param state - State object
|
|
953
|
+
*/
|
|
954
|
+
onStateChange(id, state) {
|
|
955
|
+
try {
|
|
956
|
+
if (!state) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (this.handleTriggerState(id, state)) {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (state.ack) {
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const prefix = `${this.namespace}.`;
|
|
966
|
+
const localId = id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
|
967
|
+
if (localId.startsWith("commands.")) {
|
|
968
|
+
void this.runCommand(localId, state.val);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if ((0, import_presence.parsePresenceStateId)(localId) !== null) {
|
|
972
|
+
void this.runPresence(localId, state.val);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
this.log.debug(`Ignored state change: ${localId} = ${state.val}`);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
this.log.error(`Error in onStateChange for ${id}: ${error.message}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
if (require.main !== module) {
|
|
982
|
+
module.exports = (options) => new TimeTracker(options);
|
|
983
|
+
} else {
|
|
984
|
+
(() => new TimeTracker())();
|
|
985
|
+
}
|
|
986
|
+
//# sourceMappingURL=main.js.map
|