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
|
@@ -0,0 +1,2589 @@
|
|
|
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 __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var api_exports = {};
|
|
30
|
+
__export(api_exports, {
|
|
31
|
+
MAX_BACKUP_UPLOAD_BYTES: () => MAX_BACKUP_UPLOAD_BYTES,
|
|
32
|
+
createApi: () => createApi
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(api_exports);
|
|
35
|
+
var import_node_crypto = require("node:crypto");
|
|
36
|
+
var fs = __toESM(require("node:fs"));
|
|
37
|
+
var import_audit = require("../db/repositories/audit");
|
|
38
|
+
var import_rfid = require("../db/repositories/rfid");
|
|
39
|
+
var import_auth = require("../services/auth");
|
|
40
|
+
var import_errors = require("../errors");
|
|
41
|
+
var import_seed = require("../db/seed");
|
|
42
|
+
var import_punch = require("../domain/punch");
|
|
43
|
+
var import_avatar = require("../domain/avatar");
|
|
44
|
+
var import_labels = require("../reports/labels");
|
|
45
|
+
var import_xls = require("../reports/xls");
|
|
46
|
+
var import_pdf = require("../reports/pdf");
|
|
47
|
+
var import_types = require("../reports/types");
|
|
48
|
+
var import_time = require("../util/time");
|
|
49
|
+
var import_cookies = require("./cookies");
|
|
50
|
+
var import_events = require("./events");
|
|
51
|
+
var import_pin_guard = require("./pin-guard");
|
|
52
|
+
var import_problem = require("./problem");
|
|
53
|
+
var import_router = require("./router");
|
|
54
|
+
const XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
55
|
+
const PDF_CONTENT_TYPE = "application/pdf";
|
|
56
|
+
const MAX_BACKUP_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
57
|
+
function requireString(body, field) {
|
|
58
|
+
const value = body[field];
|
|
59
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
60
|
+
throw new import_errors.ValidationError(`${field} is required`);
|
|
61
|
+
}
|
|
62
|
+
return value.trim();
|
|
63
|
+
}
|
|
64
|
+
function optionalString(body, field) {
|
|
65
|
+
const value = body[field];
|
|
66
|
+
if (value === void 0 || value === null) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== "string") {
|
|
70
|
+
throw new import_errors.ValidationError(`${field} must be a string`);
|
|
71
|
+
}
|
|
72
|
+
return value.trim() || null;
|
|
73
|
+
}
|
|
74
|
+
function optionalNumber(body, field) {
|
|
75
|
+
const value = body[field];
|
|
76
|
+
if (value === void 0 || value === null) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
80
|
+
throw new import_errors.ValidationError(`${field} must be a number`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
const SECRET_SETTING_KEYS = ["secret", "password", "token", "hash", "apikey"];
|
|
85
|
+
const JSON_SETTINGS = ["pause_staffel"];
|
|
86
|
+
const MAX_STATISTICS_DAYS = 366;
|
|
87
|
+
const TERMINAL_SESSION_MINUTES_DEFAULT = 15;
|
|
88
|
+
function publicTerminal(terminal) {
|
|
89
|
+
return {
|
|
90
|
+
id: terminal.id,
|
|
91
|
+
name: terminal.name,
|
|
92
|
+
location: terminal.location,
|
|
93
|
+
pinRequired: terminal.pinRequired,
|
|
94
|
+
isActive: terminal.isActive,
|
|
95
|
+
expiresAt: terminal.expiresAt,
|
|
96
|
+
lastSeenAt: terminal.lastSeenAt,
|
|
97
|
+
createdAt: terminal.createdAt,
|
|
98
|
+
userIds: terminal.userIds
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function editableSettings() {
|
|
102
|
+
return [...Object.keys(import_seed.SETTING_DEFAULTS), ...JSON_SETTINGS];
|
|
103
|
+
}
|
|
104
|
+
const HIDDEN_SETTING_KEYS = ["brand_logo", "brand_background"];
|
|
105
|
+
function readableSettings(all) {
|
|
106
|
+
const result = {};
|
|
107
|
+
for (const [key, value] of Object.entries(all)) {
|
|
108
|
+
const lower = key.toLowerCase();
|
|
109
|
+
if (SECRET_SETTING_KEYS.some((marker) => lower.includes(marker))) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (HIDDEN_SETTING_KEYS.includes(key)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
result[key] = value;
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
function isSettingValue(value, allowStructured) {
|
|
120
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
121
|
+
return typeof value !== "number" || Number.isFinite(value);
|
|
122
|
+
}
|
|
123
|
+
if (!allowStructured) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(value)) {
|
|
127
|
+
return value.every((entry) => isSettingValue(entry, true));
|
|
128
|
+
}
|
|
129
|
+
return typeof value === "object" && Object.values(value).every((entry) => isSettingValue(entry, true));
|
|
130
|
+
}
|
|
131
|
+
function requireBoolean(body, field) {
|
|
132
|
+
const value = optionalBoolean(body, field);
|
|
133
|
+
if (value === null) {
|
|
134
|
+
throw new import_errors.ValidationError(`${field} is required`);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
function readPeriod(year, month) {
|
|
139
|
+
const parsedYear = Number(year);
|
|
140
|
+
if (!Number.isInteger(parsedYear) || parsedYear < 2e3 || parsedYear > 2100) {
|
|
141
|
+
throw new import_errors.ValidationError(`year must be a four digit year between 2000 and 2100 (got ${JSON.stringify(year)})`);
|
|
142
|
+
}
|
|
143
|
+
if (month === null || month === void 0 || month === "") {
|
|
144
|
+
return { year: parsedYear, month: null };
|
|
145
|
+
}
|
|
146
|
+
const parsedMonth = Number(month);
|
|
147
|
+
if (!Number.isInteger(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) {
|
|
148
|
+
throw new import_errors.ValidationError(`month must be between 1 and 12 (got ${JSON.stringify(month)})`);
|
|
149
|
+
}
|
|
150
|
+
return { year: parsedYear, month: parsedMonth };
|
|
151
|
+
}
|
|
152
|
+
function optionalNumberQuery(context, name, fallback) {
|
|
153
|
+
const raw = context.query(name);
|
|
154
|
+
if (raw === null) {
|
|
155
|
+
return fallback;
|
|
156
|
+
}
|
|
157
|
+
const value = Number(raw);
|
|
158
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
159
|
+
throw new import_errors.ValidationError(`${name} must be a non-negative whole number (got ${raw})`);
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
function readRoleKeys(body) {
|
|
164
|
+
const value = body.roleKeys;
|
|
165
|
+
if (value === void 0 || value === null) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
169
|
+
throw new import_errors.ValidationError("roleKeys must be an array of strings");
|
|
170
|
+
}
|
|
171
|
+
return value.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
172
|
+
}
|
|
173
|
+
function readUserIds(body) {
|
|
174
|
+
const value = body.userIds;
|
|
175
|
+
if (value === void 0 || value === null) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
if (!Array.isArray(value) || value.some((entry) => !Number.isInteger(entry))) {
|
|
179
|
+
throw new import_errors.ValidationError("userIds must be an array of whole numbers");
|
|
180
|
+
}
|
|
181
|
+
return [...new Set(value)];
|
|
182
|
+
}
|
|
183
|
+
const PROFILE_FIELDS = [
|
|
184
|
+
"percent",
|
|
185
|
+
"weeklyHours",
|
|
186
|
+
"workdays",
|
|
187
|
+
"startDate",
|
|
188
|
+
"endDate",
|
|
189
|
+
"overtimeCarryover",
|
|
190
|
+
"vorholzeitPerYear",
|
|
191
|
+
"vacationCarryover",
|
|
192
|
+
"vacationPerYear",
|
|
193
|
+
"overtimeModel",
|
|
194
|
+
"holidayFlags",
|
|
195
|
+
"pausePaidMinutes"
|
|
196
|
+
];
|
|
197
|
+
function readProfilePatch(body) {
|
|
198
|
+
const patch = {};
|
|
199
|
+
const issues = [];
|
|
200
|
+
for (const [key, value] of Object.entries(body)) {
|
|
201
|
+
if (key === "reason") {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!PROFILE_FIELDS.includes(key)) {
|
|
205
|
+
issues.push({ path: key, message: "unknown field of a work profile" });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (key === "percent") {
|
|
209
|
+
const percent = Number(value);
|
|
210
|
+
if (!Number.isInteger(percent) || percent < 0 || percent > 100) {
|
|
211
|
+
issues.push({ path: key, message: "must be a whole number between 0 and 100" });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
patch.percent = percent;
|
|
215
|
+
} else if (key === "weeklyHours") {
|
|
216
|
+
const hours = Number(value);
|
|
217
|
+
if (!Number.isFinite(hours) || hours < 0 || hours > 168) {
|
|
218
|
+
issues.push({ path: key, message: "must be between 0 and 168" });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
patch.weeklyHours = hours;
|
|
222
|
+
} else if (key === "workdays") {
|
|
223
|
+
if (typeof value !== "string") {
|
|
224
|
+
issues.push({ path: key, message: "must be a string of weekdays" });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const workdays = value.trim();
|
|
228
|
+
if (!/^\d(;\d)*$/.test(workdays) || workdays.split(";").some((day) => Number(day) > 6)) {
|
|
229
|
+
issues.push({ path: key, message: 'must be weekdays separated by ";" (0 = Sunday \u2026 6 = Saturday)' });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
patch.workdays = workdays;
|
|
233
|
+
} else if (key === "overtimeModel") {
|
|
234
|
+
if (value !== "cumulative" && value !== "yearly" && value !== "monthly") {
|
|
235
|
+
issues.push({ path: key, message: "must be cumulative, yearly or monthly" });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
patch.overtimeModel = value;
|
|
239
|
+
} else if (key === "startDate" || key === "endDate") {
|
|
240
|
+
if (value !== null && (!Number.isInteger(Number(value)) || Number(value) < 0)) {
|
|
241
|
+
issues.push({ path: key, message: "must be an instant in seconds or null" });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
patch[key] = value === null ? null : Number(value);
|
|
245
|
+
} else if (key === "holidayFlags") {
|
|
246
|
+
if (value !== null && typeof value !== "string") {
|
|
247
|
+
issues.push({ path: key, message: "must be a JSON string or null" });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
patch.holidayFlags = value;
|
|
251
|
+
} else if (key === "pausePaidMinutes") {
|
|
252
|
+
const minutes = Number(value);
|
|
253
|
+
if (!Number.isInteger(minutes) || minutes < 0 || minutes > 1440) {
|
|
254
|
+
issues.push({ path: key, message: "must be a whole number of minutes between 0 and 1440" });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
patch.pausePaidMinutes = minutes;
|
|
258
|
+
} else {
|
|
259
|
+
const number = Number(value);
|
|
260
|
+
if (!Number.isInteger(number)) {
|
|
261
|
+
issues.push({ path: key, message: "must be a whole number of minutes" });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
patch[key] = number;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (issues.length > 0) {
|
|
268
|
+
throw new import_errors.ValidationError(`${issues.length} field(s) of the work profile are not valid`, issues);
|
|
269
|
+
}
|
|
270
|
+
return patch;
|
|
271
|
+
}
|
|
272
|
+
function publicUser(user) {
|
|
273
|
+
return {
|
|
274
|
+
id: user.id,
|
|
275
|
+
login: user.login,
|
|
276
|
+
displayName: user.displayName,
|
|
277
|
+
email: user.email,
|
|
278
|
+
rfidCard: user.rfidCard,
|
|
279
|
+
isActive: user.isActive,
|
|
280
|
+
mustChangePw: user.mustChangePw,
|
|
281
|
+
locale: user.locale,
|
|
282
|
+
language: user.locale,
|
|
283
|
+
timezone: user.timezone,
|
|
284
|
+
// the picture is served by its own route, so it never travels inside the JSON answer; the version in the
|
|
285
|
+
// query makes the browser fetch it again after a change
|
|
286
|
+
avatarUrl: user.avatar ? `/api/users/${user.id}/avatar?v=${user.updatedAt}` : null,
|
|
287
|
+
createdAt: user.createdAt,
|
|
288
|
+
updatedAt: user.updatedAt,
|
|
289
|
+
lastLoginAt: user.lastLoginAt
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function optionalBoolean(body, field) {
|
|
293
|
+
const value = body[field];
|
|
294
|
+
if (value === void 0 || value === null) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
if (typeof value !== "boolean") {
|
|
298
|
+
throw new import_errors.ValidationError(`${field} must be a boolean`);
|
|
299
|
+
}
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
function numberParam(context, name) {
|
|
303
|
+
const raw = context.params[name];
|
|
304
|
+
const value = Number(raw);
|
|
305
|
+
if (!Number.isInteger(value)) {
|
|
306
|
+
throw new import_errors.ValidationError(`${name} must be a whole number (got ${raw})`);
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
function optionalDirection(body) {
|
|
311
|
+
const value = optionalString(body, "direction");
|
|
312
|
+
if (value === null) {
|
|
313
|
+
return "auto";
|
|
314
|
+
}
|
|
315
|
+
if (value !== "in" && value !== "out" && value !== "auto") {
|
|
316
|
+
throw new import_errors.ValidationError(`direction must be in, out or auto (got ${value})`);
|
|
317
|
+
}
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
function resolveScope(context, requested, ownPermission, otherPermission) {
|
|
321
|
+
if (!context.auth) {
|
|
322
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
323
|
+
}
|
|
324
|
+
const target = requested != null ? requested : context.auth.user.id;
|
|
325
|
+
if (target === context.auth.user.id) {
|
|
326
|
+
return target;
|
|
327
|
+
}
|
|
328
|
+
if (!context.auth.permissions.includes(otherPermission)) {
|
|
329
|
+
throw (0, import_problem.problem)(403, "permission_denied", `request rejected (permission_denied: ${otherPermission})`);
|
|
330
|
+
}
|
|
331
|
+
return target;
|
|
332
|
+
}
|
|
333
|
+
function buildEntriesCsv(entries, timeZone) {
|
|
334
|
+
var _a, _b;
|
|
335
|
+
const lines = ["date;time;direction;source;note"];
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
const seconds = (0, import_time.utcToWallTime)(entry.tsUtc, timeZone) % 86400;
|
|
338
|
+
const hour = String(Math.floor(seconds / 3600)).padStart(2, "0");
|
|
339
|
+
const minute = String(Math.floor(seconds % 3600 / 60)).padStart(2, "0");
|
|
340
|
+
const note = `"${((_a = entry.note) != null ? _a : "").replace(/"/g, '""')}"`;
|
|
341
|
+
lines.push([entry.localDate, `${hour}:${minute}`, (_b = entry.direction) != null ? _b : "", entry.source, note].join(";"));
|
|
342
|
+
}
|
|
343
|
+
return Buffer.from(`\uFEFF${lines.join("\r\n")}\r
|
|
344
|
+
`, "utf8");
|
|
345
|
+
}
|
|
346
|
+
function createApi(deps) {
|
|
347
|
+
var _a, _b, _c, _d;
|
|
348
|
+
const {
|
|
349
|
+
auth,
|
|
350
|
+
users,
|
|
351
|
+
entries,
|
|
352
|
+
absences,
|
|
353
|
+
holidays,
|
|
354
|
+
rules,
|
|
355
|
+
payouts,
|
|
356
|
+
terminals,
|
|
357
|
+
rfid,
|
|
358
|
+
triggers,
|
|
359
|
+
automations,
|
|
360
|
+
aggregation,
|
|
361
|
+
sync,
|
|
362
|
+
settings
|
|
363
|
+
} = deps;
|
|
364
|
+
const now = (_a = deps.now) != null ? _a : (() => Math.floor(Date.now() / 1e3));
|
|
365
|
+
const events = (_b = deps.events) != null ? _b : (0, import_events.createEventBus)();
|
|
366
|
+
const registered = [];
|
|
367
|
+
const router = (0, import_router.createRouter)({ auth, trustProxy: deps.trustProxy === true, now });
|
|
368
|
+
const pinGuard = (0, import_pin_guard.createPinGuard)();
|
|
369
|
+
const emit = (event) => {
|
|
370
|
+
events.publish({ ...event, atUtc: now() });
|
|
371
|
+
};
|
|
372
|
+
function requireInsideEditWindow(context, ownerId, tsUtc) {
|
|
373
|
+
const actor = context.auth;
|
|
374
|
+
if (!actor || actor.user.id !== ownerId || actor.permissions.includes("time.edit_other")) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const days = settings.getNumber("edit_window_days", 0);
|
|
378
|
+
if (days <= 0) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (tsUtc < now() - days * 86400) {
|
|
382
|
+
throw (0, import_problem.problem)(
|
|
383
|
+
403,
|
|
384
|
+
"edit_window_closed",
|
|
385
|
+
`own punches may only be changed within ${days} day(s) (edit_window_days)`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const route = (method, path, routeSettings, handler) => {
|
|
390
|
+
const methods = Array.isArray(method) ? method : [method];
|
|
391
|
+
for (const entry of methods) {
|
|
392
|
+
registered.push({
|
|
393
|
+
method: entry,
|
|
394
|
+
path,
|
|
395
|
+
...routeSettings.permission ? { permission: routeSettings.permission } : {}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
router.add({
|
|
399
|
+
method,
|
|
400
|
+
path,
|
|
401
|
+
permission: routeSettings.permission,
|
|
402
|
+
requiresAuth: routeSettings.public !== true,
|
|
403
|
+
// `undefined` hands the decision to the router: a session cookie needs the CSRF token, a bearer
|
|
404
|
+
// client (integration, `x-session-token`) does not, because no foreign page can set that header.
|
|
405
|
+
// `false` switches the check off completely — that is for the public routes, which run without a
|
|
406
|
+
// session, so a CSRF token could not exist yet.
|
|
407
|
+
requiresCsrf: routeSettings.csrf === false ? false : void 0,
|
|
408
|
+
rateLimit: routeSettings.rateLimit,
|
|
409
|
+
maxBodyBytes: routeSettings.maxBodyBytes,
|
|
410
|
+
handler
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
const sessionCookie = (context, token, expiresAt) => (0, import_cookies.serializeCookie)(import_cookies.SESSION_COOKIE, token, {
|
|
414
|
+
maxAgeSeconds: Math.max(0, expiresAt - now()),
|
|
415
|
+
secure: context.secure
|
|
416
|
+
});
|
|
417
|
+
const removeSessionCookie = (context) => (0, import_cookies.clearCookie)(import_cookies.SESSION_COOKIE, { secure: context.secure });
|
|
418
|
+
route(
|
|
419
|
+
"POST",
|
|
420
|
+
"/auth/login",
|
|
421
|
+
{
|
|
422
|
+
public: true,
|
|
423
|
+
csrf: false,
|
|
424
|
+
rateLimit: { name: "login", limit: (_c = deps.loginRateLimit) != null ? _c : 20, windowSeconds: 60 }
|
|
425
|
+
},
|
|
426
|
+
(context) => {
|
|
427
|
+
var _a2;
|
|
428
|
+
const body = context.jsonBody();
|
|
429
|
+
const result = auth.login({
|
|
430
|
+
login: requireString(body, "login"),
|
|
431
|
+
password: requireString(body, "password"),
|
|
432
|
+
userAgent: context.header("user-agent"),
|
|
433
|
+
ip: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
434
|
+
now: now()
|
|
435
|
+
});
|
|
436
|
+
if (!result.ok) {
|
|
437
|
+
throw (0, import_problem.problem)(
|
|
438
|
+
result.error === "locked_out" ? 423 : 401,
|
|
439
|
+
result.error,
|
|
440
|
+
result.error === "locked_out" ? "too many failed attempts, try again later" : "login or password is not correct"
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return (0, import_router.json)(
|
|
444
|
+
200,
|
|
445
|
+
{
|
|
446
|
+
token: result.token,
|
|
447
|
+
csrfToken: result.csrfToken,
|
|
448
|
+
expiresAt: result.expiresAt,
|
|
449
|
+
user: result.user
|
|
450
|
+
},
|
|
451
|
+
{ "set-cookie": sessionCookie(context, result.token, result.expiresAt) }
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
route("POST", "/auth/logout", { csrf: true }, (context) => {
|
|
456
|
+
var _a2, _b2, _c2;
|
|
457
|
+
auth.logout({
|
|
458
|
+
token: context.sessionToken,
|
|
459
|
+
actorId: (_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : null,
|
|
460
|
+
ip: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
|
|
461
|
+
now: now()
|
|
462
|
+
});
|
|
463
|
+
return (0, import_router.noContent)(204, { "set-cookie": removeSessionCookie(context) });
|
|
464
|
+
});
|
|
465
|
+
route(
|
|
466
|
+
"GET",
|
|
467
|
+
"/auth/me",
|
|
468
|
+
{},
|
|
469
|
+
(context) => {
|
|
470
|
+
var _a2, _b2, _c2, _d2;
|
|
471
|
+
return (0, import_router.json)(200, {
|
|
472
|
+
user: (_a2 = context.auth) == null ? void 0 : _a2.user,
|
|
473
|
+
permissions: (_c2 = (_b2 = context.auth) == null ? void 0 : _b2.permissions) != null ? _c2 : [],
|
|
474
|
+
expiresAt: (_d2 = context.auth) == null ? void 0 : _d2.expiresAt,
|
|
475
|
+
// a client that only holds the cookie has no other way to learn the CSRF token of its session
|
|
476
|
+
csrfToken: context.auth ? auth.csrfToken(context.sessionToken) : void 0
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
);
|
|
480
|
+
route("POST", "/auth/password", { csrf: true }, (context) => {
|
|
481
|
+
if (!context.auth) {
|
|
482
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
483
|
+
}
|
|
484
|
+
const password = requireString(context.jsonBody(), "password");
|
|
485
|
+
auth.setPassword({ userId: context.auth.user.id, password, actorId: context.auth.user.id, now: now() });
|
|
486
|
+
auth.revokeSessions({ userId: context.auth.user.id, actorId: context.auth.user.id, now: now() });
|
|
487
|
+
return (0, import_router.noContent)(204, { "set-cookie": removeSessionCookie(context) });
|
|
488
|
+
});
|
|
489
|
+
route("POST", "/auth/refresh", { csrf: true }, (context) => {
|
|
490
|
+
if (!context.auth) {
|
|
491
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
492
|
+
}
|
|
493
|
+
const token = context.sessionToken;
|
|
494
|
+
return (0, import_router.json)(200, {
|
|
495
|
+
expiresAt: context.auth.expiresAt,
|
|
496
|
+
csrfToken: auth.csrfToken(token),
|
|
497
|
+
user: context.auth.user,
|
|
498
|
+
permissions: context.auth.permissions
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
const scopeUser = (context, ownPermission = "report.view_own", otherPermission = "report.view_other") => {
|
|
502
|
+
const raw = context.query("userId");
|
|
503
|
+
return resolveScope(context, raw ? Number(raw) : null, ownPermission, otherPermission);
|
|
504
|
+
};
|
|
505
|
+
const numberQuery = (context, name) => {
|
|
506
|
+
const raw = context.query(name);
|
|
507
|
+
const value = Number(raw);
|
|
508
|
+
if (raw === null || raw.trim() === "" || !Number.isInteger(value)) {
|
|
509
|
+
throw new import_errors.ValidationError(`${name} is required and must be a whole number`);
|
|
510
|
+
}
|
|
511
|
+
return value;
|
|
512
|
+
};
|
|
513
|
+
const punch = (context, options) => {
|
|
514
|
+
var _a2, _b2;
|
|
515
|
+
if (!context.auth) {
|
|
516
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
517
|
+
}
|
|
518
|
+
const body = context.jsonBody();
|
|
519
|
+
const timestamp = now();
|
|
520
|
+
const user = users.findById(context.auth.user.id);
|
|
521
|
+
if (!user) {
|
|
522
|
+
throw new import_errors.NotFoundError(`user ${context.auth.user.id} not found`);
|
|
523
|
+
}
|
|
524
|
+
const requestedTs = (_a2 = optionalNumber(body, "tsUtc")) != null ? _a2 : timestamp;
|
|
525
|
+
const quickRoundMinutes = options.quick ? settings.getNumber("quick_round_minutes", 0) : 0;
|
|
526
|
+
const tsUtc = quickRoundMinutes > 0 ? (0, import_punch.roundToStep)(requestedTs, quickRoundMinutes, user.timezone) : requestedTs;
|
|
527
|
+
const stored = entries.insert({
|
|
528
|
+
userId: user.id,
|
|
529
|
+
tsUtc,
|
|
530
|
+
clientTsUtc: optionalNumber(body, "clientTsUtc"),
|
|
531
|
+
timeZone: user.timezone,
|
|
532
|
+
source: "web",
|
|
533
|
+
direction: optionalDirection(body),
|
|
534
|
+
idempotencyKey: optionalString(body, "idempotencyKey"),
|
|
535
|
+
note: optionalString(body, "note"),
|
|
536
|
+
actorId: user.id,
|
|
537
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
538
|
+
now: timestamp
|
|
539
|
+
});
|
|
540
|
+
const day = aggregation.recalculateDay(user.id, stored.entry.localDate, { now: timestamp });
|
|
541
|
+
emit({
|
|
542
|
+
type: "punch",
|
|
543
|
+
userId: user.id,
|
|
544
|
+
data: { entryId: stored.entry.id, created: stored.created, localDate: stored.entry.localDate }
|
|
545
|
+
});
|
|
546
|
+
return (0, import_router.json)(
|
|
547
|
+
201,
|
|
548
|
+
{
|
|
549
|
+
entry: stored.entry,
|
|
550
|
+
created: stored.created,
|
|
551
|
+
day,
|
|
552
|
+
...quickRoundMinutes > 0 ? { rounded: { from: requestedTs, to: tsUtc, roundMinutes: quickRoundMinutes } } : {}
|
|
553
|
+
},
|
|
554
|
+
{ location: `/entries/${stored.entry.id}` }
|
|
555
|
+
);
|
|
556
|
+
};
|
|
557
|
+
route(
|
|
558
|
+
"POST",
|
|
559
|
+
"/punch",
|
|
560
|
+
{ permission: "time.punch", csrf: true, rateLimit: { name: "punch", limit: 60, windowSeconds: 60 } },
|
|
561
|
+
(context) => punch(context, { quick: false })
|
|
562
|
+
);
|
|
563
|
+
route(
|
|
564
|
+
"POST",
|
|
565
|
+
"/punch/quick",
|
|
566
|
+
{ permission: "time.punch", csrf: true, rateLimit: { name: "punch", limit: 60, windowSeconds: 60 } },
|
|
567
|
+
(context) => punch(context, { quick: true })
|
|
568
|
+
);
|
|
569
|
+
route("GET", "/punch/status", { permission: "report.view_own" }, (context) => {
|
|
570
|
+
const userId = scopeUser(context);
|
|
571
|
+
const user = users.findById(userId);
|
|
572
|
+
if (!user) {
|
|
573
|
+
throw new import_errors.NotFoundError(`user ${userId} not found`);
|
|
574
|
+
}
|
|
575
|
+
const date = (0, import_time.localDate)(now(), user.timezone);
|
|
576
|
+
const day = aggregation.recalculateDay(userId, date, { now: now() });
|
|
577
|
+
const todayEntries = entries.listByRange(userId, date, date);
|
|
578
|
+
return (0, import_router.json)(200, {
|
|
579
|
+
date,
|
|
580
|
+
day,
|
|
581
|
+
hasOpenEntry: day.hasOpenEntry,
|
|
582
|
+
lastEntry: todayEntries.length > 0 ? todayEntries[todayEntries.length - 1] : null,
|
|
583
|
+
// what a punch would do right now, so a client does not have to derive it
|
|
584
|
+
nextDirection: day.hasOpenEntry ? "out" : "in"
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
route(
|
|
588
|
+
"POST",
|
|
589
|
+
"/entries/sync",
|
|
590
|
+
{ permission: "time.punch", csrf: true, rateLimit: { name: "sync", limit: 30, windowSeconds: 60 } },
|
|
591
|
+
(context) => {
|
|
592
|
+
var _a2;
|
|
593
|
+
if (!context.auth) {
|
|
594
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
595
|
+
}
|
|
596
|
+
const body = context.jsonBody();
|
|
597
|
+
const user = users.findById(context.auth.user.id);
|
|
598
|
+
if (!user) {
|
|
599
|
+
throw new import_errors.NotFoundError(`user ${context.auth.user.id} not found`);
|
|
600
|
+
}
|
|
601
|
+
if (!Array.isArray(body.punches)) {
|
|
602
|
+
throw new import_errors.ValidationError("punches must be an array");
|
|
603
|
+
}
|
|
604
|
+
const result = sync.sync({
|
|
605
|
+
userId: user.id,
|
|
606
|
+
timeZone: user.timezone,
|
|
607
|
+
punches: body.punches.map((raw) => {
|
|
608
|
+
const punch2 = raw != null ? raw : {};
|
|
609
|
+
const tsUtc = optionalNumber(punch2, "tsUtc");
|
|
610
|
+
if (tsUtc === null) {
|
|
611
|
+
throw new import_errors.ValidationError("tsUtc is required for every queued punch");
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
idempotencyKey: typeof punch2.idempotencyKey === "string" ? punch2.idempotencyKey : "",
|
|
615
|
+
tsUtc,
|
|
616
|
+
clientTsUtc: optionalNumber(punch2, "clientTsUtc"),
|
|
617
|
+
direction: optionalDirection(punch2),
|
|
618
|
+
note: optionalString(punch2, "note")
|
|
619
|
+
};
|
|
620
|
+
}),
|
|
621
|
+
actorId: user.id,
|
|
622
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
623
|
+
now: now()
|
|
624
|
+
});
|
|
625
|
+
emit({
|
|
626
|
+
type: "punch",
|
|
627
|
+
userId: user.id,
|
|
628
|
+
data: {
|
|
629
|
+
synced: result.accepted.filter((entry) => entry.created).length,
|
|
630
|
+
duplicates: result.accepted.filter((entry) => !entry.created).length,
|
|
631
|
+
conflicts: result.conflicts.length,
|
|
632
|
+
rejected: result.rejected.length,
|
|
633
|
+
days: result.recalculated.length
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
return (0, import_router.json)(200, result);
|
|
637
|
+
}
|
|
638
|
+
);
|
|
639
|
+
route(
|
|
640
|
+
"GET",
|
|
641
|
+
"/entries/conflicts",
|
|
642
|
+
{ permission: "time.resolve_conflict" },
|
|
643
|
+
(context) => {
|
|
644
|
+
var _a2, _b2;
|
|
645
|
+
return (0, import_router.json)(200, { conflicts: sync.conflicts((_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : 0) });
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
route("POST", "/entries/:id/resolve", { permission: "time.resolve_conflict", csrf: true }, (context) => {
|
|
649
|
+
var _a2, _b2;
|
|
650
|
+
if (!context.auth) {
|
|
651
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
652
|
+
}
|
|
653
|
+
const body = context.jsonBody();
|
|
654
|
+
const action = requireString(body, "action");
|
|
655
|
+
if (action !== "accept" && action !== "dismiss") {
|
|
656
|
+
throw new import_errors.ValidationError(`action must be accept or dismiss (got ${action})`);
|
|
657
|
+
}
|
|
658
|
+
const result = sync.resolve({
|
|
659
|
+
entryId: numberParam(context, "id"),
|
|
660
|
+
action,
|
|
661
|
+
tsUtc: (_a2 = optionalNumber(body, "tsUtc")) != null ? _a2 : void 0,
|
|
662
|
+
reason: optionalString(body, "reason"),
|
|
663
|
+
actorId: context.auth.user.id,
|
|
664
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
665
|
+
now: now()
|
|
666
|
+
});
|
|
667
|
+
if (result.entry) {
|
|
668
|
+
emit({
|
|
669
|
+
type: "entry.update",
|
|
670
|
+
userId: result.entry.userId,
|
|
671
|
+
data: { entryId: result.entry.id, localDate: result.entry.localDate, resolved: action }
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
return (0, import_router.json)(200, result);
|
|
675
|
+
});
|
|
676
|
+
route("GET", "/aggregates/day", { permission: "report.view_own" }, (context) => {
|
|
677
|
+
var _a2, _b2;
|
|
678
|
+
const userId = scopeUser(context);
|
|
679
|
+
const from = context.query("from");
|
|
680
|
+
const to = (_a2 = context.query("to")) != null ? _a2 : from;
|
|
681
|
+
if (from && to) {
|
|
682
|
+
const range = aggregation.recalculateRange(userId, from, to, { now: now() });
|
|
683
|
+
return (0, import_router.json)(200, { from, to, ...range, days: aggregation.days(userId, from, to) });
|
|
684
|
+
}
|
|
685
|
+
const user = users.findById(userId);
|
|
686
|
+
if (!user) {
|
|
687
|
+
throw new import_errors.NotFoundError(`user ${userId} not found`);
|
|
688
|
+
}
|
|
689
|
+
const date = (_b2 = context.query("date")) != null ? _b2 : (0, import_time.localDate)(now(), user.timezone);
|
|
690
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
691
|
+
throw new import_errors.ValidationError(`date must be YYYY-MM-DD (got ${date})`);
|
|
692
|
+
}
|
|
693
|
+
return (0, import_router.json)(200, { day: aggregation.recalculateDay(userId, date, { now: now() }) });
|
|
694
|
+
});
|
|
695
|
+
route("GET", "/aggregates/month", { permission: "report.view_own" }, (context) => {
|
|
696
|
+
const userId = scopeUser(context);
|
|
697
|
+
const year = numberQuery(context, "year");
|
|
698
|
+
const requestedMonth = context.query("month");
|
|
699
|
+
if (!requestedMonth) {
|
|
700
|
+
const totals = aggregation.recalculateYear(userId, year, { now: now() });
|
|
701
|
+
const months = Array.from({ length: 12 }, (_, index) => aggregation.month(userId, year, index + 1));
|
|
702
|
+
return (0, import_router.json)(200, { year: totals, months });
|
|
703
|
+
}
|
|
704
|
+
const month = Number(requestedMonth);
|
|
705
|
+
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
|
706
|
+
throw new import_errors.ValidationError(`month must be between 1 and 12 (got ${requestedMonth})`);
|
|
707
|
+
}
|
|
708
|
+
const monthly = aggregation.recalculateMonth(userId, year, month, { now: now() });
|
|
709
|
+
const yearly = aggregation.recalculateYear(userId, year, { now: now() });
|
|
710
|
+
return (0, import_router.json)(200, { month: monthly, year: yearly });
|
|
711
|
+
});
|
|
712
|
+
route("GET", "/aggregates/year", { permission: "report.view_own" }, (context) => {
|
|
713
|
+
const userId = scopeUser(context);
|
|
714
|
+
const year = numberQuery(context, "year");
|
|
715
|
+
return (0, import_router.json)(200, { year: aggregation.recalculateYear(userId, year, { now: now() }) });
|
|
716
|
+
});
|
|
717
|
+
route("GET", "/entries", { permission: "report.view_own" }, (context) => {
|
|
718
|
+
var _a2;
|
|
719
|
+
const userId = scopeUser(context);
|
|
720
|
+
const from = context.query("from");
|
|
721
|
+
const to = (_a2 = context.query("to")) != null ? _a2 : from;
|
|
722
|
+
if (!from || !to) {
|
|
723
|
+
throw new import_errors.ValidationError("from and to are required");
|
|
724
|
+
}
|
|
725
|
+
return (0, import_router.json)(200, { entries: entries.listByRange(userId, from, to) });
|
|
726
|
+
});
|
|
727
|
+
route("POST", "/entries", { permission: "time.edit_own", csrf: true }, (context) => {
|
|
728
|
+
var _a2, _b2;
|
|
729
|
+
if (!context.auth) {
|
|
730
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
731
|
+
}
|
|
732
|
+
const body = context.jsonBody();
|
|
733
|
+
const target = Number((_a2 = context.query("userId")) != null ? _a2 : context.auth.user.id);
|
|
734
|
+
if (!Number.isInteger(target)) {
|
|
735
|
+
throw new import_errors.ValidationError("userId must be a whole number");
|
|
736
|
+
}
|
|
737
|
+
const own = target === context.auth.user.id;
|
|
738
|
+
if (!own && !context.auth.permissions.includes("time.edit_other")) {
|
|
739
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
|
|
740
|
+
}
|
|
741
|
+
const user = users.findById(target);
|
|
742
|
+
if (!user) {
|
|
743
|
+
throw new import_errors.NotFoundError(`user ${target} not found`);
|
|
744
|
+
}
|
|
745
|
+
const tsUtc = optionalNumber(body, "tsUtc");
|
|
746
|
+
if (tsUtc === null) {
|
|
747
|
+
throw new import_errors.ValidationError("tsUtc is required");
|
|
748
|
+
}
|
|
749
|
+
requireInsideEditWindow(context, target, tsUtc);
|
|
750
|
+
const timestamp = now();
|
|
751
|
+
const stored = entries.insert({
|
|
752
|
+
userId: target,
|
|
753
|
+
tsUtc,
|
|
754
|
+
clientTsUtc: optionalNumber(body, "clientTsUtc"),
|
|
755
|
+
timeZone: user.timezone,
|
|
756
|
+
source: own ? "web" : "admin",
|
|
757
|
+
direction: optionalDirection(body),
|
|
758
|
+
idempotencyKey: optionalString(body, "idempotencyKey"),
|
|
759
|
+
note: optionalString(body, "note"),
|
|
760
|
+
reason: optionalString(body, "reason"),
|
|
761
|
+
actorId: context.auth.user.id,
|
|
762
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
763
|
+
now: timestamp
|
|
764
|
+
});
|
|
765
|
+
const day = aggregation.recalculateDay(target, stored.entry.localDate, { now: timestamp });
|
|
766
|
+
emit({
|
|
767
|
+
type: "punch",
|
|
768
|
+
userId: target,
|
|
769
|
+
data: {
|
|
770
|
+
entryId: stored.entry.id,
|
|
771
|
+
created: stored.created,
|
|
772
|
+
localDate: stored.entry.localDate,
|
|
773
|
+
source: own ? "web" : "admin"
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
return (0, import_router.json)(
|
|
777
|
+
201,
|
|
778
|
+
{ entry: stored.entry, created: stored.created, day },
|
|
779
|
+
{ location: `/entries/${stored.entry.id}` }
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
const publicAbsence = (absence) => {
|
|
783
|
+
var _a2, _b2;
|
|
784
|
+
const type = absences.findType(absence.typeId);
|
|
785
|
+
return { ...absence, typeCode: (_a2 = type == null ? void 0 : type.code) != null ? _a2 : null, typeName: (_b2 = type == null ? void 0 : type.name) != null ? _b2 : null };
|
|
786
|
+
};
|
|
787
|
+
route("GET", "/absences", { permission: "report.view_own" }, (context) => {
|
|
788
|
+
const requested = context.query("userId") ? Number(context.query("userId")) : null;
|
|
789
|
+
const userId = resolveScope(context, requested, "report.view_own", "report.view_other");
|
|
790
|
+
const year = context.query("year") ? Number(context.query("year")) : void 0;
|
|
791
|
+
return (0, import_router.json)(200, {
|
|
792
|
+
absences: absences.listByUser(userId, year === void 0 ? {} : { year }).map(publicAbsence)
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
route("POST", "/absences", { permission: "absence.request", csrf: true }, (context) => {
|
|
796
|
+
var _a2, _b2, _c2, _d2, _e;
|
|
797
|
+
if (!context.auth) {
|
|
798
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
799
|
+
}
|
|
800
|
+
const body = context.jsonBody();
|
|
801
|
+
const userId = resolveScope(context, optionalNumber(body, "userId"), "absence.request", "absence.edit_other");
|
|
802
|
+
const status = optionalString(body, "status");
|
|
803
|
+
if (status !== null && status !== "taken" && status !== "planned") {
|
|
804
|
+
throw new import_errors.ValidationError(`status must be taken or planned (got ${status})`);
|
|
805
|
+
}
|
|
806
|
+
const created = absences.create({
|
|
807
|
+
userId,
|
|
808
|
+
typeCode: (_a2 = optionalString(body, "typeCode")) != null ? _a2 : void 0,
|
|
809
|
+
typeId: (_b2 = optionalNumber(body, "typeId")) != null ? _b2 : void 0,
|
|
810
|
+
dateFrom: requireString(body, "dateFrom"),
|
|
811
|
+
dateTo: (_c2 = optionalString(body, "dateTo")) != null ? _c2 : void 0,
|
|
812
|
+
dayPortion: (_d2 = optionalNumber(body, "dayPortion")) != null ? _d2 : void 0,
|
|
813
|
+
hours: optionalNumber(body, "hours"),
|
|
814
|
+
status: status != null ? status : void 0,
|
|
815
|
+
note: optionalString(body, "note"),
|
|
816
|
+
actorId: context.auth.user.id,
|
|
817
|
+
actorIp: (_e = context.request.remoteAddress) != null ? _e : null,
|
|
818
|
+
now: now()
|
|
819
|
+
});
|
|
820
|
+
emit({ type: "absence.change", userId, data: { absenceId: created.id, action: "created" } });
|
|
821
|
+
return (0, import_router.json)(201, { absence: publicAbsence(created) }, { location: `/absences/${created.id}` });
|
|
822
|
+
});
|
|
823
|
+
route("POST", "/absences/:id/status", { permission: "absence.approve", csrf: true }, (context) => {
|
|
824
|
+
var _a2;
|
|
825
|
+
if (!context.auth) {
|
|
826
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
827
|
+
}
|
|
828
|
+
const status = requireString(context.jsonBody(), "status");
|
|
829
|
+
if (status !== "taken" && status !== "planned") {
|
|
830
|
+
throw new import_errors.ValidationError(`status must be taken or planned (got ${status})`);
|
|
831
|
+
}
|
|
832
|
+
const updated = absences.setStatus({
|
|
833
|
+
id: numberParam(context, "id"),
|
|
834
|
+
status,
|
|
835
|
+
actorId: context.auth.user.id,
|
|
836
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
837
|
+
now: now()
|
|
838
|
+
});
|
|
839
|
+
emit({ type: "absence.change", userId: updated.userId, data: { absenceId: updated.id, action: "status" } });
|
|
840
|
+
return (0, import_router.json)(200, { absence: publicAbsence(updated) });
|
|
841
|
+
});
|
|
842
|
+
const changeableAbsence = (context) => {
|
|
843
|
+
if (!context.auth) {
|
|
844
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
845
|
+
}
|
|
846
|
+
const absence = absences.findById(numberParam(context, "id"));
|
|
847
|
+
if (!absence) {
|
|
848
|
+
throw new import_errors.NotFoundError(`absence ${context.params.id} not found`);
|
|
849
|
+
}
|
|
850
|
+
if (absence.userId !== context.auth.user.id && !context.auth.permissions.includes("absence.edit_other")) {
|
|
851
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: absence.edit_other)");
|
|
852
|
+
}
|
|
853
|
+
return absence;
|
|
854
|
+
};
|
|
855
|
+
route("PATCH", "/absences/:id", { permission: "absence.request", csrf: true }, (context) => {
|
|
856
|
+
var _a2, _b2, _c2, _d2, _e, _f, _g, _h, _i, _j, _k, _l, _m;
|
|
857
|
+
const absence = changeableAbsence(context);
|
|
858
|
+
const body = context.jsonBody();
|
|
859
|
+
const status = optionalString(body, "status");
|
|
860
|
+
if (status !== null && status !== "taken" && status !== "planned") {
|
|
861
|
+
throw new import_errors.ValidationError(`status must be taken or planned (got ${status})`);
|
|
862
|
+
}
|
|
863
|
+
if (status !== null && !((_a2 = context.auth) == null ? void 0 : _a2.permissions.includes("absence.approve"))) {
|
|
864
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: absence.approve)");
|
|
865
|
+
}
|
|
866
|
+
const patch = {
|
|
867
|
+
typeId: (_b2 = optionalNumber(body, "typeId")) != null ? _b2 : void 0,
|
|
868
|
+
typeCode: (_c2 = optionalString(body, "typeCode")) != null ? _c2 : void 0,
|
|
869
|
+
dateFrom: (_d2 = optionalString(body, "dateFrom")) != null ? _d2 : void 0,
|
|
870
|
+
dateTo: (_e = optionalString(body, "dateTo")) != null ? _e : void 0,
|
|
871
|
+
dayPortion: (_f = optionalNumber(body, "dayPortion")) != null ? _f : void 0,
|
|
872
|
+
...Object.prototype.hasOwnProperty.call(body, "hours") ? { hours: optionalNumber(body, "hours") } : {},
|
|
873
|
+
...Object.prototype.hasOwnProperty.call(body, "note") ? { note: optionalString(body, "note") } : {}
|
|
874
|
+
};
|
|
875
|
+
const hasFields = Object.values(patch).some((value) => value !== void 0);
|
|
876
|
+
if (status !== null) {
|
|
877
|
+
absences.setStatus({
|
|
878
|
+
id: absence.id,
|
|
879
|
+
status,
|
|
880
|
+
actorId: (_h = (_g = context.auth) == null ? void 0 : _g.user.id) != null ? _h : 0,
|
|
881
|
+
actorIp: (_i = context.request.remoteAddress) != null ? _i : null,
|
|
882
|
+
now: now()
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
const updated = hasFields ? absences.update({
|
|
886
|
+
id: absence.id,
|
|
887
|
+
patch,
|
|
888
|
+
reason: optionalString(body, "reason"),
|
|
889
|
+
actorId: (_k = (_j = context.auth) == null ? void 0 : _j.user.id) != null ? _k : 0,
|
|
890
|
+
actorIp: (_l = context.request.remoteAddress) != null ? _l : null,
|
|
891
|
+
now: now()
|
|
892
|
+
}) : (_m = absences.findById(absence.id)) != null ? _m : absence;
|
|
893
|
+
emit({ type: "absence.change", userId: updated.userId, data: { absenceId: updated.id, action: "updated" } });
|
|
894
|
+
return (0, import_router.json)(200, { absence: publicAbsence(updated) });
|
|
895
|
+
});
|
|
896
|
+
route("DELETE", "/absences/:id", { permission: "absence.request", csrf: true }, (context) => {
|
|
897
|
+
var _a2, _b2, _c2;
|
|
898
|
+
const absence = changeableAbsence(context);
|
|
899
|
+
const removed = absences.remove({
|
|
900
|
+
id: absence.id,
|
|
901
|
+
actorId: (_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : 0,
|
|
902
|
+
actorIp: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
|
|
903
|
+
now: now()
|
|
904
|
+
});
|
|
905
|
+
if (!removed) {
|
|
906
|
+
throw new import_errors.NotFoundError(`absence ${absence.id} not found`);
|
|
907
|
+
}
|
|
908
|
+
emit({ type: "absence.change", userId: absence.userId, data: { absenceId: absence.id, action: "deleted" } });
|
|
909
|
+
return (0, import_router.noContent)();
|
|
910
|
+
});
|
|
911
|
+
route(
|
|
912
|
+
"GET",
|
|
913
|
+
"/absence-types",
|
|
914
|
+
{},
|
|
915
|
+
(context) => {
|
|
916
|
+
var _a2, _b2;
|
|
917
|
+
return (0, import_router.json)(200, { types: absences.types({ userId: (_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : null }) });
|
|
918
|
+
}
|
|
919
|
+
);
|
|
920
|
+
route("POST", "/absence-types", { permission: "absence.manage_types", csrf: true }, (context) => {
|
|
921
|
+
var _a2, _b2, _c2, _d2, _e;
|
|
922
|
+
if (!context.auth) {
|
|
923
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
924
|
+
}
|
|
925
|
+
const body = context.jsonBody();
|
|
926
|
+
const result = absences.upsertType({
|
|
927
|
+
userId: optionalNumber(body, "userId"),
|
|
928
|
+
code: requireString(body, "code"),
|
|
929
|
+
name: requireString(body, "name"),
|
|
930
|
+
paid: (_a2 = optionalBoolean(body, "paid")) != null ? _a2 : void 0,
|
|
931
|
+
factor: (_b2 = optionalNumber(body, "factor")) != null ? _b2 : void 0,
|
|
932
|
+
reduceVacation: (_c2 = optionalBoolean(body, "reduceVacation")) != null ? _c2 : void 0,
|
|
933
|
+
isActive: (_d2 = optionalBoolean(body, "isActive")) != null ? _d2 : void 0,
|
|
934
|
+
actorId: context.auth.user.id,
|
|
935
|
+
actorIp: (_e = context.request.remoteAddress) != null ? _e : null,
|
|
936
|
+
now: now()
|
|
937
|
+
});
|
|
938
|
+
return (0, import_router.json)(result.created ? 201 : 200, { type: result.type, created: result.created });
|
|
939
|
+
});
|
|
940
|
+
route("GET", "/holidays", { permission: "report.view_own" }, (context) => {
|
|
941
|
+
var _a2;
|
|
942
|
+
const year = numberQuery(context, "year");
|
|
943
|
+
const region = (_a2 = context.query("region")) != null ? _a2 : void 0;
|
|
944
|
+
return (0, import_router.json)(200, { year, region: region != null ? region : null, holidays: holidays.listByYear(year, region) });
|
|
945
|
+
});
|
|
946
|
+
route("POST", "/holidays", { permission: "holiday.manage", csrf: true }, (context) => {
|
|
947
|
+
var _a2, _b2;
|
|
948
|
+
if (!context.auth) {
|
|
949
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
950
|
+
}
|
|
951
|
+
const body = context.jsonBody();
|
|
952
|
+
const created = holidays.add({
|
|
953
|
+
date: requireString(body, "date"),
|
|
954
|
+
name: requireString(body, "name"),
|
|
955
|
+
region: (_a2 = optionalString(body, "region")) != null ? _a2 : void 0,
|
|
956
|
+
actorId: context.auth.user.id,
|
|
957
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
958
|
+
now: now()
|
|
959
|
+
});
|
|
960
|
+
return (0, import_router.json)(201, { holiday: created }, { location: `/holidays/${created.id}` });
|
|
961
|
+
});
|
|
962
|
+
route("DELETE", "/holidays/:id", { permission: "holiday.manage", csrf: true }, (context) => {
|
|
963
|
+
var _a2;
|
|
964
|
+
if (!context.auth) {
|
|
965
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
966
|
+
}
|
|
967
|
+
const removed = holidays.remove({
|
|
968
|
+
id: numberParam(context, "id"),
|
|
969
|
+
actorId: context.auth.user.id,
|
|
970
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
971
|
+
now: now()
|
|
972
|
+
});
|
|
973
|
+
if (!removed) {
|
|
974
|
+
throw new import_errors.NotFoundError(`holiday ${context.params.id} not found`);
|
|
975
|
+
}
|
|
976
|
+
return (0, import_router.noContent)();
|
|
977
|
+
});
|
|
978
|
+
route(
|
|
979
|
+
"GET",
|
|
980
|
+
"/settings",
|
|
981
|
+
{ permission: "settings.view" },
|
|
982
|
+
() => (0, import_router.json)(200, { settings: readableSettings(settings.all()) })
|
|
983
|
+
);
|
|
984
|
+
route("PUT", "/settings", { permission: "settings.edit", csrf: true }, (context) => {
|
|
985
|
+
var _a2, _b2;
|
|
986
|
+
const body = context.jsonBody();
|
|
987
|
+
const changes = {};
|
|
988
|
+
for (const [key, value] of Object.entries(body)) {
|
|
989
|
+
if (!editableSettings().includes(key)) {
|
|
990
|
+
throw new import_errors.ValidationError(`setting "${key}" cannot be changed through the API`);
|
|
991
|
+
}
|
|
992
|
+
if (key === "brand_logo" || key === "brand_background") {
|
|
993
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
994
|
+
if (raw !== "" && !(0, import_avatar.parseAvatarDataUrl)(raw, import_avatar.MAX_BRANDING_BYTES)) {
|
|
995
|
+
throw new import_errors.ValidationError(
|
|
996
|
+
`setting "${key}" must be a data URL of a png, jpeg, webp or gif of up to ${import_avatar.MAX_BRANDING_BYTES} bytes`
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
changes[key] = raw;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (key === "brand_color") {
|
|
1003
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
1004
|
+
if (raw !== "" && !/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(raw)) {
|
|
1005
|
+
throw new import_errors.ValidationError('setting "brand_color" must be a hex colour like "#1a2b3c"');
|
|
1006
|
+
}
|
|
1007
|
+
changes[key] = raw;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (key === "pause_mode") {
|
|
1011
|
+
if (value !== "auto" && value !== "punched" && value !== "staffel") {
|
|
1012
|
+
throw new import_errors.ValidationError('setting "pause_mode" must be "auto", "punched" or "staffel"');
|
|
1013
|
+
}
|
|
1014
|
+
changes[key] = value;
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (!isSettingValue(value, JSON_SETTINGS.includes(key))) {
|
|
1018
|
+
throw new import_errors.ValidationError(`setting "${key}" has an unsupported value`);
|
|
1019
|
+
}
|
|
1020
|
+
changes[key] = value;
|
|
1021
|
+
}
|
|
1022
|
+
if (Object.keys(changes).length === 0) {
|
|
1023
|
+
throw new import_errors.ValidationError("no settings given");
|
|
1024
|
+
}
|
|
1025
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
1026
|
+
settings.set(key, value, (_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : null, now());
|
|
1027
|
+
}
|
|
1028
|
+
return (0, import_router.json)(200, { settings: changes });
|
|
1029
|
+
});
|
|
1030
|
+
function replacePauseRules(context, userId) {
|
|
1031
|
+
var _a2;
|
|
1032
|
+
if (!context.auth) {
|
|
1033
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1034
|
+
}
|
|
1035
|
+
const body = context.jsonBody();
|
|
1036
|
+
if (!Array.isArray(body.pauseRules)) {
|
|
1037
|
+
throw new import_errors.ValidationError("pauseRules must be an array");
|
|
1038
|
+
}
|
|
1039
|
+
const actor = {
|
|
1040
|
+
actorId: context.auth.user.id,
|
|
1041
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1042
|
+
now: now()
|
|
1043
|
+
};
|
|
1044
|
+
const wanted = body.pauseRules.map((raw) => {
|
|
1045
|
+
var _a3, _b2;
|
|
1046
|
+
const rule = raw != null ? raw : {};
|
|
1047
|
+
return {
|
|
1048
|
+
id: (_a3 = optionalNumber(rule, "id")) != null ? _a3 : void 0,
|
|
1049
|
+
fromMin: Number(rule.fromMin),
|
|
1050
|
+
toMin: optionalNumber(rule, "toMin"),
|
|
1051
|
+
pauseMin: Number(rule.pauseMin),
|
|
1052
|
+
isActive: (_b2 = optionalBoolean(rule, "isActive")) != null ? _b2 : void 0
|
|
1053
|
+
};
|
|
1054
|
+
});
|
|
1055
|
+
const keep = new Set(wanted.map((rule) => rule.id).filter((value) => value !== void 0));
|
|
1056
|
+
for (const existing of rules.listPauseRules({ userId, includeInactive: true })) {
|
|
1057
|
+
if (!keep.has(existing.id)) {
|
|
1058
|
+
rules.removePauseRule({ id: existing.id, ...actor });
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return wanted.map(
|
|
1062
|
+
(rule) => rules.savePauseRule({
|
|
1063
|
+
...rule.id !== void 0 ? { id: rule.id } : {},
|
|
1064
|
+
userId,
|
|
1065
|
+
fromMin: rule.fromMin,
|
|
1066
|
+
toMin: rule.toMin,
|
|
1067
|
+
pauseMin: rule.pauseMin,
|
|
1068
|
+
...rule.isActive !== void 0 ? { isActive: rule.isActive } : {},
|
|
1069
|
+
...actor
|
|
1070
|
+
})
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
route(
|
|
1074
|
+
"GET",
|
|
1075
|
+
"/pause-rules",
|
|
1076
|
+
{ permission: "settings.view" },
|
|
1077
|
+
() => (0, import_router.json)(200, { pauseRules: rules.listPauseRules({ userId: null, includeInactive: true }) })
|
|
1078
|
+
);
|
|
1079
|
+
route(
|
|
1080
|
+
"PUT",
|
|
1081
|
+
"/pause-rules",
|
|
1082
|
+
{ permission: "settings.edit", csrf: true },
|
|
1083
|
+
(context) => (0, import_router.json)(200, { pauseRules: replacePauseRules(context, null) })
|
|
1084
|
+
);
|
|
1085
|
+
route("GET", "/users/:id/pause-rules", { permission: "user.view" }, (context) => {
|
|
1086
|
+
const id = numberParam(context, "id");
|
|
1087
|
+
if (!users.findById(id)) {
|
|
1088
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1089
|
+
}
|
|
1090
|
+
return (0, import_router.json)(200, { pauseRules: rules.listPauseRules({ userId: id, includeInactive: true }) });
|
|
1091
|
+
});
|
|
1092
|
+
route("PUT", "/users/:id/pause-rules", { permission: "user.edit", csrf: true }, (context) => {
|
|
1093
|
+
const id = numberParam(context, "id");
|
|
1094
|
+
if (!users.findById(id)) {
|
|
1095
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1096
|
+
}
|
|
1097
|
+
return (0, import_router.json)(200, { pauseRules: replacePauseRules(context, id) });
|
|
1098
|
+
});
|
|
1099
|
+
function replaceTriggerRules(context) {
|
|
1100
|
+
var _a2, _b2;
|
|
1101
|
+
if (!context.auth) {
|
|
1102
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1103
|
+
}
|
|
1104
|
+
const body = context.jsonBody();
|
|
1105
|
+
if (!Array.isArray(body.triggerRules)) {
|
|
1106
|
+
throw new import_errors.ValidationError("triggerRules must be an array");
|
|
1107
|
+
}
|
|
1108
|
+
const actor = {
|
|
1109
|
+
actorId: context.auth.user.id,
|
|
1110
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1111
|
+
now: now()
|
|
1112
|
+
};
|
|
1113
|
+
const wanted = body.triggerRules.map((raw) => {
|
|
1114
|
+
var _a3, _b3, _c2, _d2, _e, _f;
|
|
1115
|
+
const rule = raw != null ? raw : {};
|
|
1116
|
+
const mode = (_a3 = optionalString(rule, "mode")) != null ? _a3 : "condition";
|
|
1117
|
+
const target = optionalNumber(rule, "userId");
|
|
1118
|
+
if (mode === "condition" && target !== null && !users.findById(target)) {
|
|
1119
|
+
throw new import_errors.ValidationError(`userId must reference an existing employee (got ${target})`);
|
|
1120
|
+
}
|
|
1121
|
+
if (mode === "user" && target !== null) {
|
|
1122
|
+
throw new import_errors.ValidationError("mode user reads the employee from the state, userId has to stay empty");
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
id: (_b3 = optionalNumber(rule, "id")) != null ? _b3 : void 0,
|
|
1126
|
+
label: optionalString(rule, "label"),
|
|
1127
|
+
sourceState: (_c2 = optionalString(rule, "sourceState")) != null ? _c2 : "",
|
|
1128
|
+
mode,
|
|
1129
|
+
condition: optionalString(rule, "condition"),
|
|
1130
|
+
userId: target,
|
|
1131
|
+
action: (_d2 = optionalString(rule, "action")) != null ? _d2 : "punch",
|
|
1132
|
+
isActive: (_e = optionalBoolean(rule, "isActive")) != null ? _e : true,
|
|
1133
|
+
cooldownSec: (_f = optionalNumber(rule, "cooldownSec")) != null ? _f : 0
|
|
1134
|
+
};
|
|
1135
|
+
});
|
|
1136
|
+
const keep = new Set(wanted.map((rule) => rule.id).filter((value) => value !== void 0));
|
|
1137
|
+
for (const existing of triggers.list({ includeInactive: true })) {
|
|
1138
|
+
if (!keep.has(existing.id)) {
|
|
1139
|
+
triggers.remove({ id: existing.id, ...actor });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const saved = wanted.map(
|
|
1143
|
+
(rule) => triggers.save({
|
|
1144
|
+
...rule.id !== void 0 ? { id: rule.id } : {},
|
|
1145
|
+
label: rule.label,
|
|
1146
|
+
sourceState: rule.sourceState,
|
|
1147
|
+
mode: rule.mode,
|
|
1148
|
+
condition: rule.condition,
|
|
1149
|
+
userId: rule.userId,
|
|
1150
|
+
action: rule.action,
|
|
1151
|
+
isActive: rule.isActive,
|
|
1152
|
+
cooldownSec: rule.cooldownSec,
|
|
1153
|
+
...actor
|
|
1154
|
+
})
|
|
1155
|
+
);
|
|
1156
|
+
(_b2 = deps.onTriggerRulesChanged) == null ? void 0 : _b2.call(deps);
|
|
1157
|
+
return saved;
|
|
1158
|
+
}
|
|
1159
|
+
route(
|
|
1160
|
+
"GET",
|
|
1161
|
+
"/trigger-rules",
|
|
1162
|
+
{ permission: "settings.view" },
|
|
1163
|
+
() => (0, import_router.json)(200, { triggerRules: triggers.list({ includeInactive: true }) })
|
|
1164
|
+
);
|
|
1165
|
+
route(
|
|
1166
|
+
"PUT",
|
|
1167
|
+
"/trigger-rules",
|
|
1168
|
+
{ permission: "settings.edit", csrf: true },
|
|
1169
|
+
(context) => (0, import_router.json)(200, { triggerRules: replaceTriggerRules(context) })
|
|
1170
|
+
);
|
|
1171
|
+
function replaceAutomationRules(context) {
|
|
1172
|
+
var _a2;
|
|
1173
|
+
if (!context.auth) {
|
|
1174
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1175
|
+
}
|
|
1176
|
+
const body = context.jsonBody();
|
|
1177
|
+
if (!Array.isArray(body.automationRules)) {
|
|
1178
|
+
throw new import_errors.ValidationError("automationRules must be an array");
|
|
1179
|
+
}
|
|
1180
|
+
const actor = {
|
|
1181
|
+
actorId: context.auth.user.id,
|
|
1182
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1183
|
+
now: now()
|
|
1184
|
+
};
|
|
1185
|
+
const wanted = body.automationRules.map((raw) => {
|
|
1186
|
+
var _a3, _b2, _c2;
|
|
1187
|
+
const rule = raw != null ? raw : {};
|
|
1188
|
+
const target = optionalNumber(rule, "userId");
|
|
1189
|
+
if (target !== null && !users.findById(target)) {
|
|
1190
|
+
throw new import_errors.ValidationError(`userId must reference an existing employee (got ${target})`);
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
id: (_a3 = optionalNumber(rule, "id")) != null ? _a3 : void 0,
|
|
1194
|
+
label: optionalString(rule, "label"),
|
|
1195
|
+
kind: (_b2 = optionalString(rule, "kind")) != null ? _b2 : "clockOut",
|
|
1196
|
+
userId: target,
|
|
1197
|
+
atMinute: optionalNumber(rule, "atMinute"),
|
|
1198
|
+
afterMinutes: optionalNumber(rule, "afterMinutes"),
|
|
1199
|
+
// the repository validates the selection and the repeat: a wrong entry comes back as a 400
|
|
1200
|
+
weekdays: Array.isArray(rule.weekdays) ? rule.weekdays.map(Number) : void 0,
|
|
1201
|
+
repeat: optionalString(rule, "repeat"),
|
|
1202
|
+
isActive: (_c2 = optionalBoolean(rule, "isActive")) != null ? _c2 : true
|
|
1203
|
+
};
|
|
1204
|
+
});
|
|
1205
|
+
const keep = new Set(wanted.map((rule) => rule.id).filter((value) => value !== void 0));
|
|
1206
|
+
for (const existing of automations.list({ includeInactive: true })) {
|
|
1207
|
+
if (!keep.has(existing.id)) {
|
|
1208
|
+
automations.remove({ id: existing.id, ...actor });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return wanted.map(
|
|
1212
|
+
(rule) => automations.save({
|
|
1213
|
+
...rule.id !== void 0 ? { id: rule.id } : {},
|
|
1214
|
+
label: rule.label,
|
|
1215
|
+
kind: rule.kind,
|
|
1216
|
+
userId: rule.userId,
|
|
1217
|
+
atMinute: rule.atMinute,
|
|
1218
|
+
afterMinutes: rule.afterMinutes,
|
|
1219
|
+
...rule.weekdays === void 0 ? {} : { weekdays: rule.weekdays },
|
|
1220
|
+
...rule.repeat === void 0 ? {} : { repeat: rule.repeat },
|
|
1221
|
+
isActive: rule.isActive,
|
|
1222
|
+
...actor
|
|
1223
|
+
})
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
route(
|
|
1227
|
+
"GET",
|
|
1228
|
+
"/automation-rules",
|
|
1229
|
+
{ permission: "settings.view" },
|
|
1230
|
+
() => (0, import_router.json)(200, { automationRules: automations.list({ includeInactive: true }) })
|
|
1231
|
+
);
|
|
1232
|
+
route(
|
|
1233
|
+
"GET",
|
|
1234
|
+
"/automation-rules/runs",
|
|
1235
|
+
{ permission: "settings.view" },
|
|
1236
|
+
() => (0, import_router.json)(200, { runs: automations.runs({ limit: 20 }) })
|
|
1237
|
+
);
|
|
1238
|
+
route(
|
|
1239
|
+
"PUT",
|
|
1240
|
+
"/automation-rules",
|
|
1241
|
+
{ permission: "settings.edit", csrf: true },
|
|
1242
|
+
(context) => (0, import_router.json)(200, { automationRules: replaceAutomationRules(context) })
|
|
1243
|
+
);
|
|
1244
|
+
const brandingVersion = (value) => (0, import_node_crypto.createHash)("sha256").update(value).digest("hex").slice(0, 12);
|
|
1245
|
+
const brandingState = () => {
|
|
1246
|
+
var _a2, _b2, _c2;
|
|
1247
|
+
const color = ((_a2 = settings.get("brand_color")) != null ? _a2 : "").trim();
|
|
1248
|
+
const logo = ((_b2 = settings.get("brand_logo")) != null ? _b2 : "").trim();
|
|
1249
|
+
const background = ((_c2 = settings.get("brand_background")) != null ? _c2 : "").trim();
|
|
1250
|
+
return {
|
|
1251
|
+
color: color === "" ? null : color,
|
|
1252
|
+
logoUrl: logo === "" ? null : `/api/branding/logo?v=${brandingVersion(logo)}`,
|
|
1253
|
+
backgroundUrl: background === "" ? null : `/api/branding/background?v=${brandingVersion(background)}`
|
|
1254
|
+
};
|
|
1255
|
+
};
|
|
1256
|
+
route("GET", "/branding", { public: true }, () => (0, import_router.json)(200, brandingState()));
|
|
1257
|
+
route("GET", "/branding/logo", { public: true }, () => {
|
|
1258
|
+
const image = (0, import_avatar.readAvatar)(settings.get("brand_logo"));
|
|
1259
|
+
if (!image) {
|
|
1260
|
+
throw new import_errors.NotFoundError("no logo is configured");
|
|
1261
|
+
}
|
|
1262
|
+
return (0, import_router.binary)(200, Buffer.from(image.base64, "base64"), image.contentType, {
|
|
1263
|
+
"cache-control": "public, max-age=86400"
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
1266
|
+
route("GET", "/branding/background", { public: true }, () => {
|
|
1267
|
+
const image = (0, import_avatar.readAvatar)(settings.get("brand_background"));
|
|
1268
|
+
if (!image) {
|
|
1269
|
+
throw new import_errors.NotFoundError("no background is configured");
|
|
1270
|
+
}
|
|
1271
|
+
return (0, import_router.binary)(200, Buffer.from(image.base64, "base64"), image.contentType, {
|
|
1272
|
+
"cache-control": "public, max-age=86400"
|
|
1273
|
+
});
|
|
1274
|
+
});
|
|
1275
|
+
route(
|
|
1276
|
+
"GET",
|
|
1277
|
+
"/version",
|
|
1278
|
+
{ public: true },
|
|
1279
|
+
() => {
|
|
1280
|
+
var _a2;
|
|
1281
|
+
return (0, import_router.json)(200, { name: "iobroker.time-tracker", version: (_a2 = deps.version) != null ? _a2 : "0.0.0" });
|
|
1282
|
+
}
|
|
1283
|
+
);
|
|
1284
|
+
route("PATCH", "/entries/:id", { permission: "time.edit_own", csrf: true }, (context) => {
|
|
1285
|
+
var _a2, _b2;
|
|
1286
|
+
if (!context.auth) {
|
|
1287
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1288
|
+
}
|
|
1289
|
+
const entryId = numberParam(context, "id");
|
|
1290
|
+
const existing = entries.findById(entryId);
|
|
1291
|
+
if (!existing) {
|
|
1292
|
+
throw new import_errors.NotFoundError(`entry ${entryId} not found`);
|
|
1293
|
+
}
|
|
1294
|
+
if (existing.userId !== context.auth.user.id && !context.auth.permissions.includes("time.edit_other")) {
|
|
1295
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
|
|
1296
|
+
}
|
|
1297
|
+
const body = context.jsonBody();
|
|
1298
|
+
const revision = optionalNumber(body, "revision");
|
|
1299
|
+
if (revision === null) {
|
|
1300
|
+
throw new import_errors.ValidationError("revision is required");
|
|
1301
|
+
}
|
|
1302
|
+
requireInsideEditWindow(context, existing.userId, existing.tsUtc);
|
|
1303
|
+
const wantedTs = optionalNumber(body, "tsUtc");
|
|
1304
|
+
if (wantedTs !== null) {
|
|
1305
|
+
requireInsideEditWindow(context, existing.userId, wantedTs);
|
|
1306
|
+
}
|
|
1307
|
+
const target = users.findById(existing.userId);
|
|
1308
|
+
if (!target) {
|
|
1309
|
+
throw new import_errors.NotFoundError(`user ${existing.userId} not found`);
|
|
1310
|
+
}
|
|
1311
|
+
const updated = entries.update({
|
|
1312
|
+
id: entryId,
|
|
1313
|
+
expectedRevision: revision,
|
|
1314
|
+
patch: {
|
|
1315
|
+
tsUtc: (_a2 = optionalNumber(body, "tsUtc")) != null ? _a2 : void 0,
|
|
1316
|
+
note: optionalString(body, "note")
|
|
1317
|
+
},
|
|
1318
|
+
reason: optionalString(body, "reason"),
|
|
1319
|
+
actorId: context.auth.user.id,
|
|
1320
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
1321
|
+
timeZone: target.timezone,
|
|
1322
|
+
now: now()
|
|
1323
|
+
});
|
|
1324
|
+
const day = aggregation.recalculateDay(updated.userId, updated.localDate, { now: now() });
|
|
1325
|
+
emit({
|
|
1326
|
+
type: "entry.update",
|
|
1327
|
+
userId: updated.userId,
|
|
1328
|
+
data: { entryId: updated.id, localDate: updated.localDate, revision: updated.revision }
|
|
1329
|
+
});
|
|
1330
|
+
return (0, import_router.json)(200, { entry: updated, day });
|
|
1331
|
+
});
|
|
1332
|
+
route("DELETE", "/entries/:id", { permission: "time.delete", csrf: true }, (context) => {
|
|
1333
|
+
var _a2;
|
|
1334
|
+
if (!context.auth) {
|
|
1335
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1336
|
+
}
|
|
1337
|
+
const entryId = numberParam(context, "id");
|
|
1338
|
+
const existing = entries.findById(entryId);
|
|
1339
|
+
if (!existing) {
|
|
1340
|
+
throw new import_errors.NotFoundError(`entry ${entryId} not found`);
|
|
1341
|
+
}
|
|
1342
|
+
entries.remove({
|
|
1343
|
+
id: entryId,
|
|
1344
|
+
actorId: context.auth.user.id,
|
|
1345
|
+
reason: optionalString(context.optionalJsonBody(), "reason"),
|
|
1346
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1347
|
+
now: now()
|
|
1348
|
+
});
|
|
1349
|
+
aggregation.recalculateDay(existing.userId, existing.localDate, { now: now() });
|
|
1350
|
+
emit({
|
|
1351
|
+
type: "entry.delete",
|
|
1352
|
+
userId: existing.userId,
|
|
1353
|
+
data: { entryId: existing.id, localDate: existing.localDate }
|
|
1354
|
+
});
|
|
1355
|
+
return (0, import_router.noContent)();
|
|
1356
|
+
});
|
|
1357
|
+
route("GET", "/entries/:id/audit", { permission: "audit.view" }, (context) => {
|
|
1358
|
+
const entryId = numberParam(context, "id");
|
|
1359
|
+
if (!entries.findById(entryId)) {
|
|
1360
|
+
throw new import_errors.NotFoundError(`entry ${entryId} not found`);
|
|
1361
|
+
}
|
|
1362
|
+
return (0, import_router.json)(200, {
|
|
1363
|
+
audit: (0, import_audit.readTimeEntryAudit)(deps.db, entryId).map((row) => {
|
|
1364
|
+
var _a2, _b2;
|
|
1365
|
+
return {
|
|
1366
|
+
...row,
|
|
1367
|
+
actorName: (_b2 = (_a2 = users.findById(row.actorId)) == null ? void 0 : _a2.displayName) != null ? _b2 : null
|
|
1368
|
+
};
|
|
1369
|
+
})
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
route("GET", "/users", { permission: "user.view" }, (context) => {
|
|
1373
|
+
var _a2;
|
|
1374
|
+
const includeInactive = context.query("includeInactive") === "true";
|
|
1375
|
+
const search = ((_a2 = context.query("q")) != null ? _a2 : "").trim().toLowerCase();
|
|
1376
|
+
const limit = optionalNumberQuery(context, "limit", Number.MAX_SAFE_INTEGER);
|
|
1377
|
+
const offset = optionalNumberQuery(context, "offset", 0);
|
|
1378
|
+
const all = users.list({ includeInactive });
|
|
1379
|
+
const filtered = search ? all.filter(
|
|
1380
|
+
(user) => user.login.toLowerCase().includes(search) || user.displayName.toLowerCase().includes(search)
|
|
1381
|
+
) : all;
|
|
1382
|
+
return (0, import_router.json)(200, {
|
|
1383
|
+
total: filtered.length,
|
|
1384
|
+
offset,
|
|
1385
|
+
users: filtered.slice(offset, offset + limit).map((user) => ({
|
|
1386
|
+
...publicUser(user),
|
|
1387
|
+
roles: users.roles(user.id)
|
|
1388
|
+
}))
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
route("GET", "/users/:id", { permission: "user.view" }, (context) => {
|
|
1392
|
+
const id = numberParam(context, "id");
|
|
1393
|
+
const user = users.findById(id);
|
|
1394
|
+
if (!user) {
|
|
1395
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1396
|
+
}
|
|
1397
|
+
return (0, import_router.json)(200, {
|
|
1398
|
+
user: { ...publicUser(user), roles: users.roles(id), permissions: users.permissions(id) },
|
|
1399
|
+
profile: users.getWorkProfile(id)
|
|
1400
|
+
});
|
|
1401
|
+
});
|
|
1402
|
+
route("POST", "/users", { permission: "user.create", csrf: true }, (context) => {
|
|
1403
|
+
var _a2, _b2, _c2, _d2, _e;
|
|
1404
|
+
if (!context.auth) {
|
|
1405
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1406
|
+
}
|
|
1407
|
+
const body = context.jsonBody();
|
|
1408
|
+
const password = requireString(body, "password");
|
|
1409
|
+
const policyIssue = (0, import_auth.checkPasswordPolicy)(password);
|
|
1410
|
+
if (policyIssue) {
|
|
1411
|
+
throw new import_errors.ValidationError(policyIssue);
|
|
1412
|
+
}
|
|
1413
|
+
const created = users.create({
|
|
1414
|
+
login: requireString(body, "login"),
|
|
1415
|
+
displayName: requireString(body, "displayName"),
|
|
1416
|
+
passwordHash: (0, import_auth.hashPassword)(password),
|
|
1417
|
+
email: optionalString(body, "email"),
|
|
1418
|
+
locale: (_a2 = optionalString(body, "locale")) != null ? _a2 : void 0,
|
|
1419
|
+
timezone: (_b2 = optionalString(body, "timezone")) != null ? _b2 : void 0,
|
|
1420
|
+
mustChangePw: (_c2 = optionalBoolean(body, "mustChangePw")) != null ? _c2 : void 0,
|
|
1421
|
+
roleKeys: (_d2 = readRoleKeys(body)) != null ? _d2 : void 0,
|
|
1422
|
+
actorId: context.auth.user.id,
|
|
1423
|
+
actorIp: (_e = context.request.remoteAddress) != null ? _e : null,
|
|
1424
|
+
now: now()
|
|
1425
|
+
});
|
|
1426
|
+
return (0, import_router.json)(
|
|
1427
|
+
201,
|
|
1428
|
+
{ user: { ...publicUser(created), roles: users.roles(created.id) } },
|
|
1429
|
+
{ location: `/users/${created.id}` }
|
|
1430
|
+
);
|
|
1431
|
+
});
|
|
1432
|
+
route("GET", "/roles", { permission: "user.view" }, () => (0, import_router.json)(200, { roles: users.roleCatalog() }));
|
|
1433
|
+
route("GET", "/users/:id/avatar", { public: true }, (context) => {
|
|
1434
|
+
var _a2, _b2, _c2;
|
|
1435
|
+
const id = numberParam(context, "id");
|
|
1436
|
+
const terminalSession = (_a2 = context.query("terminalSession")) != null ? _a2 : "";
|
|
1437
|
+
const browserToken = (_c2 = (_b2 = context.header("x-session-token")) != null ? _b2 : (0, import_cookies.parseCookies)(context.header("cookie"))[import_cookies.SESSION_COOKIE]) != null ? _c2 : "";
|
|
1438
|
+
const browserOk = browserToken !== "" && deps.auth.authenticate({ token: browserToken, permission: void 0, now: now() }).ok;
|
|
1439
|
+
const terminalOk = terminalSession !== "" && terminals.findBySession(terminalSession, now()) !== null;
|
|
1440
|
+
if (!browserOk && !terminalOk) {
|
|
1441
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1442
|
+
}
|
|
1443
|
+
const user = users.findById(id);
|
|
1444
|
+
const image = user ? (0, import_avatar.readAvatar)(user.avatar) : null;
|
|
1445
|
+
if (!image) {
|
|
1446
|
+
throw new import_errors.NotFoundError(`user ${id} has no picture`);
|
|
1447
|
+
}
|
|
1448
|
+
return (0, import_router.binary)(200, Buffer.from(image.base64, "base64"), image.contentType, {
|
|
1449
|
+
"cache-control": "private, max-age=300"
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
route("PATCH", "/users/:id", { permission: "user.edit", csrf: true }, (context) => {
|
|
1453
|
+
var _a2, _b2, _c2;
|
|
1454
|
+
if (!context.auth) {
|
|
1455
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1456
|
+
}
|
|
1457
|
+
const id = numberParam(context, "id");
|
|
1458
|
+
if (!users.findById(id)) {
|
|
1459
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1460
|
+
}
|
|
1461
|
+
const body = context.jsonBody();
|
|
1462
|
+
const roleKeys = readRoleKeys(body);
|
|
1463
|
+
if (roleKeys !== null && !context.auth.permissions.includes("user.manage_roles")) {
|
|
1464
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: user.manage_roles)");
|
|
1465
|
+
}
|
|
1466
|
+
const password = optionalString(body, "password");
|
|
1467
|
+
if (password !== null) {
|
|
1468
|
+
const policyIssue = (0, import_auth.checkPasswordPolicy)(password);
|
|
1469
|
+
if (policyIssue) {
|
|
1470
|
+
throw new import_errors.ValidationError(policyIssue);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const patch = {};
|
|
1474
|
+
if (Object.prototype.hasOwnProperty.call(body, "displayName")) {
|
|
1475
|
+
patch.displayName = requireString(body, "displayName");
|
|
1476
|
+
}
|
|
1477
|
+
if (Object.prototype.hasOwnProperty.call(body, "email")) {
|
|
1478
|
+
patch.email = optionalString(body, "email");
|
|
1479
|
+
}
|
|
1480
|
+
if (Object.prototype.hasOwnProperty.call(body, "locale")) {
|
|
1481
|
+
patch.locale = requireString(body, "locale");
|
|
1482
|
+
}
|
|
1483
|
+
if (Object.prototype.hasOwnProperty.call(body, "timezone")) {
|
|
1484
|
+
const timezone = requireString(body, "timezone");
|
|
1485
|
+
if (!(0, import_time.isValidTimeZone)(timezone)) {
|
|
1486
|
+
throw new import_errors.ValidationError(`timezone is not a valid IANA name (got ${timezone})`);
|
|
1487
|
+
}
|
|
1488
|
+
patch.timezone = timezone;
|
|
1489
|
+
}
|
|
1490
|
+
if (Object.prototype.hasOwnProperty.call(body, "rfidCard")) {
|
|
1491
|
+
patch.rfidCard = optionalString(body, "rfidCard");
|
|
1492
|
+
}
|
|
1493
|
+
if (Object.prototype.hasOwnProperty.call(body, "mustChangePw")) {
|
|
1494
|
+
patch.mustChangePw = requireBoolean(body, "mustChangePw");
|
|
1495
|
+
}
|
|
1496
|
+
const isActive = optionalBoolean(body, "isActive");
|
|
1497
|
+
if (isActive !== null) {
|
|
1498
|
+
if (isActive === false && id === context.auth.user.id) {
|
|
1499
|
+
throw new import_errors.ValidationError("an account cannot deactivate itself");
|
|
1500
|
+
}
|
|
1501
|
+
patch.isActive = isActive;
|
|
1502
|
+
}
|
|
1503
|
+
if (password !== null) {
|
|
1504
|
+
patch.passwordHash = (0, import_auth.hashPassword)(password);
|
|
1505
|
+
}
|
|
1506
|
+
if (Object.prototype.hasOwnProperty.call(body, "avatar")) {
|
|
1507
|
+
const avatar = optionalString(body, "avatar");
|
|
1508
|
+
if (avatar !== null && !(0, import_avatar.parseAvatarDataUrl)(avatar)) {
|
|
1509
|
+
throw new import_errors.ValidationError(
|
|
1510
|
+
`avatar must be a data URL of a png, jpeg, webp or gif of up to ${import_avatar.MAX_AVATAR_BYTES} bytes`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
users.setAvatar({
|
|
1514
|
+
userId: id,
|
|
1515
|
+
avatar,
|
|
1516
|
+
actorId: context.auth.user.id,
|
|
1517
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1518
|
+
now: now()
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
const updated = users.update({
|
|
1522
|
+
id,
|
|
1523
|
+
patch,
|
|
1524
|
+
reason: optionalString(body, "reason"),
|
|
1525
|
+
actorId: context.auth.user.id,
|
|
1526
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
1527
|
+
now: now()
|
|
1528
|
+
});
|
|
1529
|
+
const roles = roleKeys ? users.setRoles({
|
|
1530
|
+
userId: id,
|
|
1531
|
+
roleKeys,
|
|
1532
|
+
actorId: context.auth.user.id,
|
|
1533
|
+
actorIp: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
|
|
1534
|
+
now: now()
|
|
1535
|
+
}) : users.roles(id);
|
|
1536
|
+
return (0, import_router.json)(200, { user: { ...publicUser(updated), roles } });
|
|
1537
|
+
});
|
|
1538
|
+
route("DELETE", "/users/:id", { permission: "user.deactivate", csrf: true }, (context) => {
|
|
1539
|
+
var _a2;
|
|
1540
|
+
if (!context.auth) {
|
|
1541
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1542
|
+
}
|
|
1543
|
+
const id = numberParam(context, "id");
|
|
1544
|
+
const target = users.findById(id);
|
|
1545
|
+
if (!target) {
|
|
1546
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1547
|
+
}
|
|
1548
|
+
if (!target.isActive) {
|
|
1549
|
+
throw new import_errors.NotFoundError(`user ${id} is already deactivated`);
|
|
1550
|
+
}
|
|
1551
|
+
if (id === context.auth.user.id) {
|
|
1552
|
+
throw new import_errors.ValidationError("an account cannot deactivate itself");
|
|
1553
|
+
}
|
|
1554
|
+
users.update({
|
|
1555
|
+
id,
|
|
1556
|
+
patch: { isActive: false },
|
|
1557
|
+
reason: optionalString(context.optionalJsonBody(), "reason"),
|
|
1558
|
+
actorId: context.auth.user.id,
|
|
1559
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1560
|
+
now: now()
|
|
1561
|
+
});
|
|
1562
|
+
return (0, import_router.noContent)();
|
|
1563
|
+
});
|
|
1564
|
+
route("GET", "/users/:id/profile", { permission: "user.view" }, (context) => {
|
|
1565
|
+
const id = numberParam(context, "id");
|
|
1566
|
+
if (!users.findById(id)) {
|
|
1567
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1568
|
+
}
|
|
1569
|
+
return (0, import_router.json)(200, { profile: users.getWorkProfile(id) });
|
|
1570
|
+
});
|
|
1571
|
+
route("PUT", "/users/:id/profile", { permission: "user.edit", csrf: true }, (context) => {
|
|
1572
|
+
var _a2;
|
|
1573
|
+
if (!context.auth) {
|
|
1574
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1575
|
+
}
|
|
1576
|
+
const id = numberParam(context, "id");
|
|
1577
|
+
if (!users.findById(id)) {
|
|
1578
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1579
|
+
}
|
|
1580
|
+
const body = context.jsonBody();
|
|
1581
|
+
const profile = users.saveWorkProfile({
|
|
1582
|
+
userId: id,
|
|
1583
|
+
profile: readProfilePatch(body),
|
|
1584
|
+
actorId: context.auth.user.id,
|
|
1585
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1586
|
+
now: now()
|
|
1587
|
+
});
|
|
1588
|
+
return (0, import_router.json)(200, { profile });
|
|
1589
|
+
});
|
|
1590
|
+
route("GET", "/users/:id/shift-rules", { permission: "user.view" }, (context) => {
|
|
1591
|
+
const id = numberParam(context, "id");
|
|
1592
|
+
if (!users.findById(id)) {
|
|
1593
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1594
|
+
}
|
|
1595
|
+
return (0, import_router.json)(200, { shiftRules: rules.shiftRules(id, { includeInactive: true }) });
|
|
1596
|
+
});
|
|
1597
|
+
route("PUT", "/users/:id/shift-rules", { permission: "user.edit", csrf: true }, (context) => {
|
|
1598
|
+
var _a2;
|
|
1599
|
+
if (!context.auth) {
|
|
1600
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1601
|
+
}
|
|
1602
|
+
const id = numberParam(context, "id");
|
|
1603
|
+
if (!users.findById(id)) {
|
|
1604
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1605
|
+
}
|
|
1606
|
+
const body = context.jsonBody();
|
|
1607
|
+
if (!Array.isArray(body.shiftRules)) {
|
|
1608
|
+
throw new import_errors.ValidationError("shiftRules must be an array");
|
|
1609
|
+
}
|
|
1610
|
+
const actor = {
|
|
1611
|
+
actorId: context.auth.user.id,
|
|
1612
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1613
|
+
now: now()
|
|
1614
|
+
};
|
|
1615
|
+
const wanted = body.shiftRules.map((raw) => {
|
|
1616
|
+
var _a3, _b2;
|
|
1617
|
+
const rule = raw != null ? raw : {};
|
|
1618
|
+
return {
|
|
1619
|
+
id: (_a3 = optionalNumber(rule, "id")) != null ? _a3 : void 0,
|
|
1620
|
+
surcharge: Number(rule.surcharge),
|
|
1621
|
+
dayOfWeek: optionalNumber(rule, "dayOfWeek"),
|
|
1622
|
+
fromMin: optionalNumber(rule, "fromMin"),
|
|
1623
|
+
toMin: optionalNumber(rule, "toMin"),
|
|
1624
|
+
isActive: (_b2 = optionalBoolean(rule, "isActive")) != null ? _b2 : void 0
|
|
1625
|
+
};
|
|
1626
|
+
});
|
|
1627
|
+
const keep = new Set(wanted.map((rule) => rule.id).filter((value) => value !== void 0));
|
|
1628
|
+
for (const existing of rules.shiftRules(id, { includeInactive: true })) {
|
|
1629
|
+
if (!keep.has(existing.id)) {
|
|
1630
|
+
rules.removeShiftRule({ id: existing.id, ...actor });
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
const saved = wanted.map(
|
|
1634
|
+
(rule) => rules.saveShiftRule({
|
|
1635
|
+
...rule.id !== void 0 ? { id: rule.id } : {},
|
|
1636
|
+
userId: id,
|
|
1637
|
+
surcharge: rule.surcharge,
|
|
1638
|
+
...rule.dayOfWeek !== null ? { dayOfWeek: rule.dayOfWeek } : {},
|
|
1639
|
+
...rule.fromMin !== null ? { fromMin: rule.fromMin } : {},
|
|
1640
|
+
...rule.toMin !== null ? { toMin: rule.toMin } : {},
|
|
1641
|
+
...rule.isActive !== void 0 ? { isActive: rule.isActive } : {},
|
|
1642
|
+
...actor
|
|
1643
|
+
})
|
|
1644
|
+
);
|
|
1645
|
+
return (0, import_router.json)(200, { shiftRules: saved });
|
|
1646
|
+
});
|
|
1647
|
+
route("GET", "/payouts", { permission: "payout.view" }, (context) => {
|
|
1648
|
+
var _a2, _b2, _c2;
|
|
1649
|
+
const requested = context.query("userId");
|
|
1650
|
+
const userId = requested ? Number(requested) : (_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : 0;
|
|
1651
|
+
if (!Number.isInteger(userId)) {
|
|
1652
|
+
throw new import_errors.ValidationError("userId must be a whole number");
|
|
1653
|
+
}
|
|
1654
|
+
const period = readPeriod(context.query("year"), context.query("month"));
|
|
1655
|
+
const list = payouts.list(
|
|
1656
|
+
userId,
|
|
1657
|
+
period.month === null ? { year: period.year } : { year: period.year, month: period.month }
|
|
1658
|
+
);
|
|
1659
|
+
return (0, import_router.json)(200, {
|
|
1660
|
+
userId,
|
|
1661
|
+
...period,
|
|
1662
|
+
totalMinutes: payouts.sumMinutes(userId, period.year, (_c2 = period.month) != null ? _c2 : void 0),
|
|
1663
|
+
payouts: list
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
route("POST", "/payouts", { permission: "payout.create", csrf: true }, (context) => {
|
|
1667
|
+
var _a2, _b2;
|
|
1668
|
+
if (!context.auth) {
|
|
1669
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1670
|
+
}
|
|
1671
|
+
const body = context.jsonBody();
|
|
1672
|
+
const target = Number((_a2 = body.userId) != null ? _a2 : context.auth.user.id);
|
|
1673
|
+
if (!Number.isInteger(target) || !users.findById(target)) {
|
|
1674
|
+
throw new import_errors.ValidationError("userId must reference an existing employee");
|
|
1675
|
+
}
|
|
1676
|
+
const period = readPeriod(body.year, body.month);
|
|
1677
|
+
const minutes = optionalNumber(body, "minutes");
|
|
1678
|
+
if (minutes === null || !Number.isInteger(minutes) || minutes === 0) {
|
|
1679
|
+
throw new import_errors.ValidationError("minutes must be a non-zero whole number");
|
|
1680
|
+
}
|
|
1681
|
+
const created = payouts.create({
|
|
1682
|
+
userId: target,
|
|
1683
|
+
...period,
|
|
1684
|
+
minutes,
|
|
1685
|
+
amount: optionalNumber(body, "amount"),
|
|
1686
|
+
note: optionalString(body, "note"),
|
|
1687
|
+
actorId: context.auth.user.id,
|
|
1688
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
1689
|
+
now: now()
|
|
1690
|
+
});
|
|
1691
|
+
return (0, import_router.json)(201, { payout: created }, { location: `/payouts/${created.id}` });
|
|
1692
|
+
});
|
|
1693
|
+
route("PATCH", "/payouts/:id", { permission: "payout.create", csrf: true }, (context) => {
|
|
1694
|
+
var _a2;
|
|
1695
|
+
if (!context.auth) {
|
|
1696
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1697
|
+
}
|
|
1698
|
+
const id = numberParam(context, "id");
|
|
1699
|
+
if (!payouts.findById(id)) {
|
|
1700
|
+
throw new import_errors.NotFoundError(`payout ${id} not found`);
|
|
1701
|
+
}
|
|
1702
|
+
const body = context.jsonBody();
|
|
1703
|
+
const minutes = optionalNumber(body, "minutes");
|
|
1704
|
+
if (minutes !== null && (!Number.isInteger(minutes) || minutes === 0)) {
|
|
1705
|
+
throw new import_errors.ValidationError("minutes must be a non-zero whole number");
|
|
1706
|
+
}
|
|
1707
|
+
const updated = payouts.update({
|
|
1708
|
+
id,
|
|
1709
|
+
patch: {
|
|
1710
|
+
...minutes !== null ? { minutes } : {},
|
|
1711
|
+
...Object.prototype.hasOwnProperty.call(body, "amount") ? { amount: optionalNumber(body, "amount") } : {},
|
|
1712
|
+
...Object.prototype.hasOwnProperty.call(body, "note") ? { note: optionalString(body, "note") } : {}
|
|
1713
|
+
},
|
|
1714
|
+
actorId: context.auth.user.id,
|
|
1715
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1716
|
+
now: now()
|
|
1717
|
+
});
|
|
1718
|
+
return (0, import_router.json)(200, { payout: updated });
|
|
1719
|
+
});
|
|
1720
|
+
route("DELETE", "/payouts/:id", { permission: "payout.create", csrf: true }, (context) => {
|
|
1721
|
+
var _a2;
|
|
1722
|
+
if (!context.auth) {
|
|
1723
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1724
|
+
}
|
|
1725
|
+
const removed = payouts.remove({
|
|
1726
|
+
id: numberParam(context, "id"),
|
|
1727
|
+
actorId: context.auth.user.id,
|
|
1728
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
1729
|
+
now: now()
|
|
1730
|
+
});
|
|
1731
|
+
if (!removed) {
|
|
1732
|
+
throw new import_errors.NotFoundError(`payout ${context.params.id} not found`);
|
|
1733
|
+
}
|
|
1734
|
+
return (0, import_router.noContent)();
|
|
1735
|
+
});
|
|
1736
|
+
route(
|
|
1737
|
+
"POST",
|
|
1738
|
+
"/entries/bulk",
|
|
1739
|
+
{ permission: "time.import", csrf: true, rateLimit: { name: "import", limit: 10, windowSeconds: 60 } },
|
|
1740
|
+
(context) => {
|
|
1741
|
+
if (!context.auth) {
|
|
1742
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1743
|
+
}
|
|
1744
|
+
const body = context.jsonBody();
|
|
1745
|
+
if (!Array.isArray(body.entries)) {
|
|
1746
|
+
throw new import_errors.ValidationError("entries must be an array");
|
|
1747
|
+
}
|
|
1748
|
+
const actor = context.auth;
|
|
1749
|
+
const timestamp = now();
|
|
1750
|
+
const touched = /* @__PURE__ */ new Map();
|
|
1751
|
+
const results = [];
|
|
1752
|
+
body.entries.forEach((raw, index) => {
|
|
1753
|
+
var _a2, _b2;
|
|
1754
|
+
try {
|
|
1755
|
+
const item = raw != null ? raw : {};
|
|
1756
|
+
const target = Number((_a2 = item.userId) != null ? _a2 : actor.user.id);
|
|
1757
|
+
if (!Number.isInteger(target)) {
|
|
1758
|
+
throw new import_errors.ValidationError("userId must be a whole number");
|
|
1759
|
+
}
|
|
1760
|
+
if (target !== actor.user.id && !actor.permissions.includes("time.edit_other")) {
|
|
1761
|
+
throw (0, import_problem.problem)(
|
|
1762
|
+
403,
|
|
1763
|
+
"permission_denied",
|
|
1764
|
+
"request rejected (permission_denied: time.edit_other)"
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
const user = users.findById(target);
|
|
1768
|
+
if (!user) {
|
|
1769
|
+
throw new import_errors.NotFoundError(`user ${target} not found`);
|
|
1770
|
+
}
|
|
1771
|
+
const tsUtc = optionalNumber(item, "tsUtc");
|
|
1772
|
+
if (tsUtc === null) {
|
|
1773
|
+
throw new import_errors.ValidationError("tsUtc is required");
|
|
1774
|
+
}
|
|
1775
|
+
const stored = entries.insert({
|
|
1776
|
+
userId: target,
|
|
1777
|
+
tsUtc,
|
|
1778
|
+
clientTsUtc: optionalNumber(item, "clientTsUtc"),
|
|
1779
|
+
timeZone: user.timezone,
|
|
1780
|
+
source: "import",
|
|
1781
|
+
direction: optionalDirection(item),
|
|
1782
|
+
idempotencyKey: optionalString(item, "idempotencyKey"),
|
|
1783
|
+
note: optionalString(item, "note"),
|
|
1784
|
+
actorId: actor.user.id,
|
|
1785
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
1786
|
+
now: timestamp
|
|
1787
|
+
});
|
|
1788
|
+
touched.set(`${target}|${stored.entry.localDate}`, target);
|
|
1789
|
+
results.push({ index, entryId: stored.entry.id });
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
const details = (0, import_problem.toProblem)(error).problem;
|
|
1792
|
+
results.push({ index, error: details.code });
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
for (const key of touched.keys()) {
|
|
1796
|
+
const [userId, localDate2] = key.split("|");
|
|
1797
|
+
aggregation.recalculateDay(Number(userId), localDate2, { now: timestamp });
|
|
1798
|
+
}
|
|
1799
|
+
const imported = results.filter((result) => result.entryId !== void 0).length;
|
|
1800
|
+
for (const [key, userId] of touched) {
|
|
1801
|
+
emit({
|
|
1802
|
+
type: "punch",
|
|
1803
|
+
userId,
|
|
1804
|
+
data: { imported: true, localDate: key.split("|")[1], source: "import" }
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
return (0, import_router.json)(200, {
|
|
1808
|
+
imported,
|
|
1809
|
+
failed: results.length - imported,
|
|
1810
|
+
recalculatedDays: touched.size,
|
|
1811
|
+
results
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
);
|
|
1815
|
+
route("GET", "/reports/statistics", { permission: "report.statistics" }, (context) => {
|
|
1816
|
+
var _a2, _b2;
|
|
1817
|
+
const from = context.query("from");
|
|
1818
|
+
const to = (_a2 = context.query("to")) != null ? _a2 : from;
|
|
1819
|
+
if (!from || !to) {
|
|
1820
|
+
throw new import_errors.ValidationError("from and to are required");
|
|
1821
|
+
}
|
|
1822
|
+
if (from > to) {
|
|
1823
|
+
throw new import_errors.ValidationError(`from (${from}) must not be after to (${to})`);
|
|
1824
|
+
}
|
|
1825
|
+
const days = (0, import_time.dateRange)(from, to).length;
|
|
1826
|
+
if (days > MAX_STATISTICS_DAYS) {
|
|
1827
|
+
throw new import_errors.ValidationError(`the range may cover at most ${MAX_STATISTICS_DAYS} days (got ${days})`);
|
|
1828
|
+
}
|
|
1829
|
+
const requested = context.query("userId");
|
|
1830
|
+
if (requested) {
|
|
1831
|
+
if (!((_b2 = context.auth) == null ? void 0 : _b2.permissions.includes("report.view_other"))) {
|
|
1832
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: report.view_other)");
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
const employees = requested ? [Number(requested)] : users.list({ includeInactive: false }).map((user) => user.id);
|
|
1836
|
+
if (employees.some((id) => !Number.isInteger(id) || !users.findById(id))) {
|
|
1837
|
+
throw new import_errors.ValidationError("userId must reference an existing employee");
|
|
1838
|
+
}
|
|
1839
|
+
const timestamp = now();
|
|
1840
|
+
const rows = employees.map((userId) => {
|
|
1841
|
+
var _a3;
|
|
1842
|
+
const user = users.findById(userId);
|
|
1843
|
+
const range = aggregation.recalculateRange(userId, from, to, { now: timestamp });
|
|
1844
|
+
return {
|
|
1845
|
+
userId,
|
|
1846
|
+
displayName: (_a3 = user == null ? void 0 : user.displayName) != null ? _a3 : "",
|
|
1847
|
+
...range
|
|
1848
|
+
};
|
|
1849
|
+
});
|
|
1850
|
+
return (0, import_router.json)(200, {
|
|
1851
|
+
from,
|
|
1852
|
+
to,
|
|
1853
|
+
users: rows,
|
|
1854
|
+
totals: rows.reduce(
|
|
1855
|
+
(sum, row) => ({
|
|
1856
|
+
workedMin: sum.workedMin + row.workedMin,
|
|
1857
|
+
targetMin: sum.targetMin + row.targetMin,
|
|
1858
|
+
balanceMin: sum.balanceMin + row.balanceMin,
|
|
1859
|
+
openDays: sum.openDays + row.openDays
|
|
1860
|
+
}),
|
|
1861
|
+
{ workedMin: 0, targetMin: 0, balanceMin: 0, openDays: 0 }
|
|
1862
|
+
)
|
|
1863
|
+
});
|
|
1864
|
+
});
|
|
1865
|
+
const requireKiosk = () => {
|
|
1866
|
+
if (deps.kioskEnabled !== true) {
|
|
1867
|
+
throw (0, import_problem.problem)(403, "kiosk_disabled", "the kiosk terminal is switched off in the adapter settings");
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
const TERMINAL_SESSION_MINUTES = (_d = deps.terminalSessionMinutes) != null ? _d : TERMINAL_SESSION_MINUTES_DEFAULT;
|
|
1871
|
+
const requireTerminal = (context) => {
|
|
1872
|
+
requireKiosk();
|
|
1873
|
+
const body = context.optionalJsonBody();
|
|
1874
|
+
const fromBody = typeof body.terminalSession === "string" ? body.terminalSession : null;
|
|
1875
|
+
const session = fromBody != null ? fromBody : context.query("terminalSession");
|
|
1876
|
+
if (!session) {
|
|
1877
|
+
throw (0, import_problem.problem)(401, "no_session", "terminal session is missing");
|
|
1878
|
+
}
|
|
1879
|
+
const terminal = terminals.findBySession(session, now());
|
|
1880
|
+
if (!terminal) {
|
|
1881
|
+
throw (0, import_problem.problem)(401, "no_session", "terminal session is not valid any more");
|
|
1882
|
+
}
|
|
1883
|
+
return terminal;
|
|
1884
|
+
};
|
|
1885
|
+
route(
|
|
1886
|
+
"GET",
|
|
1887
|
+
"/terminal/status",
|
|
1888
|
+
{ public: true },
|
|
1889
|
+
() => {
|
|
1890
|
+
var _a2, _b2;
|
|
1891
|
+
return (0, import_router.json)(200, {
|
|
1892
|
+
enabled: deps.kioskEnabled === true,
|
|
1893
|
+
serverTime: now(),
|
|
1894
|
+
timezone: (_a2 = settings.get("timezone")) != null ? _a2 : "Europe/Berlin",
|
|
1895
|
+
version: (_b2 = deps.version) != null ? _b2 : "0.0.0"
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
);
|
|
1899
|
+
route(
|
|
1900
|
+
"POST",
|
|
1901
|
+
"/terminal/session",
|
|
1902
|
+
{ public: true, csrf: false, rateLimit: { name: "terminal-session", limit: 30, windowSeconds: 60 } },
|
|
1903
|
+
(context) => {
|
|
1904
|
+
requireKiosk();
|
|
1905
|
+
const body = context.jsonBody();
|
|
1906
|
+
const deviceToken = requireString(body, "deviceToken");
|
|
1907
|
+
const terminal = terminals.findByToken(deviceToken, now());
|
|
1908
|
+
if (!terminal) {
|
|
1909
|
+
throw (0, import_problem.problem)(401, "invalid_credentials", "the device token is not valid any more");
|
|
1910
|
+
}
|
|
1911
|
+
const started = terminals.startSession({
|
|
1912
|
+
id: terminal.id,
|
|
1913
|
+
ttlMinutes: TERMINAL_SESSION_MINUTES,
|
|
1914
|
+
now: now()
|
|
1915
|
+
});
|
|
1916
|
+
return (0, import_router.json)(200, {
|
|
1917
|
+
terminalSession: started.terminalSession,
|
|
1918
|
+
expiresAt: started.expiresAt,
|
|
1919
|
+
terminal: {
|
|
1920
|
+
name: terminal.name,
|
|
1921
|
+
location: terminal.location,
|
|
1922
|
+
pinRequired: terminal.pinRequired
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
);
|
|
1927
|
+
route("POST", "/terminal/heartbeat", { public: true, csrf: false }, (context) => {
|
|
1928
|
+
var _a2, _b2;
|
|
1929
|
+
const terminal = requireTerminal(context);
|
|
1930
|
+
const extended = terminals.touch({
|
|
1931
|
+
id: terminal.id,
|
|
1932
|
+
ttlMinutes: TERMINAL_SESSION_MINUTES,
|
|
1933
|
+
now: now()
|
|
1934
|
+
});
|
|
1935
|
+
return (0, import_router.json)(200, {
|
|
1936
|
+
status: "ok",
|
|
1937
|
+
serverTime: now(),
|
|
1938
|
+
timezone: (_a2 = settings.get("timezone")) != null ? _a2 : "Europe/Berlin",
|
|
1939
|
+
version: (_b2 = deps.version) != null ? _b2 : "0.0.0",
|
|
1940
|
+
expiresAt: extended.expiresAt
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
route(
|
|
1944
|
+
"GET",
|
|
1945
|
+
"/terminal/users",
|
|
1946
|
+
{ public: true, rateLimit: { name: "terminal-users", limit: 60, windowSeconds: 60 } },
|
|
1947
|
+
(context) => {
|
|
1948
|
+
var _a2;
|
|
1949
|
+
const terminal = requireTerminal(context);
|
|
1950
|
+
const timestamp = now();
|
|
1951
|
+
const terminalSession = (_a2 = context.query("terminalSession")) != null ? _a2 : "";
|
|
1952
|
+
const candidates = users.list({ includeInactive: false });
|
|
1953
|
+
const visible = terminal.userIds.length === 0 ? candidates : candidates.filter((user) => terminal.userIds.includes(user.id));
|
|
1954
|
+
return (0, import_router.json)(200, {
|
|
1955
|
+
users: visible.map((user) => {
|
|
1956
|
+
var _a3;
|
|
1957
|
+
return {
|
|
1958
|
+
id: user.id,
|
|
1959
|
+
displayName: user.displayName,
|
|
1960
|
+
present: ((_a3 = aggregation.day(user.id, (0, import_time.localDate)(timestamp, user.timezone))) == null ? void 0 : _a3.hasOpenEntry) === true,
|
|
1961
|
+
// the picture is fetched by the browser from its own route; the session of this device travels
|
|
1962
|
+
// in the query, so a plain `<img>` can load it
|
|
1963
|
+
avatarUrl: user.avatar ? `/api/users/${user.id}/avatar?terminalSession=${encodeURIComponent(terminalSession)}&v=${user.updatedAt}` : null
|
|
1964
|
+
};
|
|
1965
|
+
})
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
);
|
|
1969
|
+
route(
|
|
1970
|
+
"POST",
|
|
1971
|
+
"/terminal/punch",
|
|
1972
|
+
{ public: true, csrf: false, rateLimit: { name: "terminal-punch", limit: 60, windowSeconds: 60 } },
|
|
1973
|
+
(context) => {
|
|
1974
|
+
var _a2, _b2;
|
|
1975
|
+
const terminal = requireTerminal(context);
|
|
1976
|
+
const body = context.jsonBody();
|
|
1977
|
+
const badge = optionalString(body, "badge");
|
|
1978
|
+
const pin = optionalString(body, "pin");
|
|
1979
|
+
const targetId = optionalNumber(body, "userId");
|
|
1980
|
+
if (badge === null && targetId === null) {
|
|
1981
|
+
throw new import_errors.ValidationError("a badge or a userId is required");
|
|
1982
|
+
}
|
|
1983
|
+
if (badge === null && terminal.pinRequired && pin === null) {
|
|
1984
|
+
throw new import_errors.ValidationError("a userId together with the PIN is required");
|
|
1985
|
+
}
|
|
1986
|
+
const user = badge !== null ? users.findByRfidCard(badge) : users.findById(targetId != null ? targetId : 0);
|
|
1987
|
+
if (!user || !user.isActive) {
|
|
1988
|
+
throw (0, import_problem.problem)(401, "invalid_credentials", "badge or PIN is not known");
|
|
1989
|
+
}
|
|
1990
|
+
const blocked = pinGuard.state({ userId: user.id, now: now() });
|
|
1991
|
+
if (blocked.locked) {
|
|
1992
|
+
throw (0, import_problem.problem)(423, "locked_out", `too many wrong PINs, try again in ${blocked.retryAfterSeconds} s`);
|
|
1993
|
+
}
|
|
1994
|
+
const pinMatches = pin !== null && user.pinHash !== null && (0, import_auth.verifyPassword)(pin, user.pinHash);
|
|
1995
|
+
if (terminal.pinRequired ? !pinMatches : pin !== null && !pinMatches) {
|
|
1996
|
+
pinGuard.fail({ userId: user.id, now: now() });
|
|
1997
|
+
throw (0, import_problem.problem)(401, "invalid_credentials", "badge or PIN is not known");
|
|
1998
|
+
}
|
|
1999
|
+
pinGuard.reset(user.id);
|
|
2000
|
+
const timestamp = now();
|
|
2001
|
+
const stored = entries.insert({
|
|
2002
|
+
userId: user.id,
|
|
2003
|
+
tsUtc: (_a2 = optionalNumber(body, "tsUtc")) != null ? _a2 : timestamp,
|
|
2004
|
+
timeZone: user.timezone,
|
|
2005
|
+
source: "terminal",
|
|
2006
|
+
direction: optionalDirection(body),
|
|
2007
|
+
note: optionalString(body, "note"),
|
|
2008
|
+
actorId: user.id,
|
|
2009
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
2010
|
+
now: timestamp
|
|
2011
|
+
});
|
|
2012
|
+
const day = aggregation.recalculateDay(user.id, stored.entry.localDate, { now: timestamp });
|
|
2013
|
+
terminals.touch({ id: terminal.id, ttlMinutes: TERMINAL_SESSION_MINUTES, now: timestamp });
|
|
2014
|
+
emit({
|
|
2015
|
+
type: "terminal.punch",
|
|
2016
|
+
userId: user.id,
|
|
2017
|
+
data: {
|
|
2018
|
+
entryId: stored.entry.id,
|
|
2019
|
+
localDate: stored.entry.localDate,
|
|
2020
|
+
terminalId: terminal.id,
|
|
2021
|
+
terminalName: terminal.name
|
|
2022
|
+
}
|
|
2023
|
+
});
|
|
2024
|
+
return (0, import_router.json)(201, {
|
|
2025
|
+
user: { id: user.id, displayName: user.displayName },
|
|
2026
|
+
entry: {
|
|
2027
|
+
id: stored.entry.id,
|
|
2028
|
+
tsUtc: stored.entry.tsUtc,
|
|
2029
|
+
direction: stored.entry.direction,
|
|
2030
|
+
localDate: stored.entry.localDate
|
|
2031
|
+
},
|
|
2032
|
+
day: {
|
|
2033
|
+
workedMin: day.workedMin,
|
|
2034
|
+
targetMin: day.targetMin,
|
|
2035
|
+
balanceMin: day.balanceMin,
|
|
2036
|
+
hasOpenEntry: day.hasOpenEntry
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
);
|
|
2041
|
+
route(
|
|
2042
|
+
"GET",
|
|
2043
|
+
"/terminals",
|
|
2044
|
+
{ permission: "terminal.manage" },
|
|
2045
|
+
() => (0, import_router.json)(200, { terminals: terminals.list({ includeInactive: true }).map(publicTerminal) })
|
|
2046
|
+
);
|
|
2047
|
+
route("POST", "/terminals", { permission: "terminal.manage", csrf: true }, (context) => {
|
|
2048
|
+
var _a2, _b2, _c2;
|
|
2049
|
+
if (!context.auth) {
|
|
2050
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2051
|
+
}
|
|
2052
|
+
const body = context.jsonBody();
|
|
2053
|
+
const created = terminals.create({
|
|
2054
|
+
name: requireString(body, "name"),
|
|
2055
|
+
location: optionalString(body, "location"),
|
|
2056
|
+
pinRequired: (_a2 = optionalBoolean(body, "pinRequired")) != null ? _a2 : void 0,
|
|
2057
|
+
userIds: (_b2 = readUserIds(body)) != null ? _b2 : void 0,
|
|
2058
|
+
ttlDays: optionalNumber(body, "ttlDays"),
|
|
2059
|
+
actorId: context.auth.user.id,
|
|
2060
|
+
actorIp: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
|
|
2061
|
+
now: now()
|
|
2062
|
+
});
|
|
2063
|
+
return (0, import_router.json)(
|
|
2064
|
+
201,
|
|
2065
|
+
{ terminal: publicTerminal(created.terminal), deviceToken: created.deviceToken },
|
|
2066
|
+
{ location: `/terminals/${created.terminal.id}` }
|
|
2067
|
+
);
|
|
2068
|
+
});
|
|
2069
|
+
route("PUT", "/terminals/:id/users", { permission: "terminal.manage", csrf: true }, (context) => {
|
|
2070
|
+
var _a2, _b2;
|
|
2071
|
+
if (!context.auth) {
|
|
2072
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2073
|
+
}
|
|
2074
|
+
const id = numberParam(context, "id");
|
|
2075
|
+
const body = context.jsonBody();
|
|
2076
|
+
const userIds = (_a2 = readUserIds(body)) != null ? _a2 : [];
|
|
2077
|
+
for (const userId of userIds) {
|
|
2078
|
+
if (!users.findById(userId)) {
|
|
2079
|
+
throw new import_errors.ValidationError(`user ${userId} does not exist`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const terminal = terminals.setUsers({
|
|
2083
|
+
id,
|
|
2084
|
+
userIds,
|
|
2085
|
+
actorId: context.auth.user.id,
|
|
2086
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
2087
|
+
now: now()
|
|
2088
|
+
});
|
|
2089
|
+
return (0, import_router.json)(200, { terminal: publicTerminal(terminal) });
|
|
2090
|
+
});
|
|
2091
|
+
route("DELETE", "/terminals/:id", { permission: "terminal.manage", csrf: true }, (context) => {
|
|
2092
|
+
var _a2;
|
|
2093
|
+
if (!context.auth) {
|
|
2094
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2095
|
+
}
|
|
2096
|
+
const revoked = terminals.revoke({
|
|
2097
|
+
id: numberParam(context, "id"),
|
|
2098
|
+
actorId: context.auth.user.id,
|
|
2099
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2100
|
+
now: now()
|
|
2101
|
+
});
|
|
2102
|
+
if (!revoked) {
|
|
2103
|
+
throw new import_errors.NotFoundError(`terminal ${context.params.id} not found or already revoked`);
|
|
2104
|
+
}
|
|
2105
|
+
return (0, import_router.noContent)();
|
|
2106
|
+
});
|
|
2107
|
+
route("POST", "/users/:id/pin", { permission: "user.edit", csrf: true }, (context) => {
|
|
2108
|
+
var _a2;
|
|
2109
|
+
if (!context.auth) {
|
|
2110
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2111
|
+
}
|
|
2112
|
+
const id = numberParam(context, "id");
|
|
2113
|
+
if (!users.findById(id)) {
|
|
2114
|
+
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
2115
|
+
}
|
|
2116
|
+
const body = context.jsonBody();
|
|
2117
|
+
const pin = optionalString(body, "pin");
|
|
2118
|
+
if (pin !== null && !/^\d{4,8}$/.test(pin)) {
|
|
2119
|
+
throw new import_errors.ValidationError("pin must be 4 to 8 digits");
|
|
2120
|
+
}
|
|
2121
|
+
users.setPin({
|
|
2122
|
+
userId: id,
|
|
2123
|
+
// a PIN is a secret of the same weight as a password, so it is hashed the same way
|
|
2124
|
+
pinHash: pin === null ? null : (0, import_auth.hashPassword)(pin),
|
|
2125
|
+
actorId: context.auth.user.id,
|
|
2126
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2127
|
+
now: now()
|
|
2128
|
+
});
|
|
2129
|
+
return (0, import_router.noContent)();
|
|
2130
|
+
});
|
|
2131
|
+
const requireHmacSecret = () => {
|
|
2132
|
+
var _a2;
|
|
2133
|
+
const secret = (_a2 = deps.hmacSecret) != null ? _a2 : "";
|
|
2134
|
+
if (!secret) {
|
|
2135
|
+
throw (0, import_problem.problem)(403, "not_configured", "no HMAC secret is configured for signed tag links");
|
|
2136
|
+
}
|
|
2137
|
+
return secret;
|
|
2138
|
+
};
|
|
2139
|
+
const tagLink = (context, token) => {
|
|
2140
|
+
var _a2;
|
|
2141
|
+
const host = (_a2 = context.header("host")) != null ? _a2 : "localhost";
|
|
2142
|
+
const scheme = host.includes("://") ? "" : `${context.secure ? "https" : "http"}://`;
|
|
2143
|
+
return `${scheme}${host}/?tag=${token}`;
|
|
2144
|
+
};
|
|
2145
|
+
const reissueLink = (context, tag, ttlDays, label) => {
|
|
2146
|
+
var _a2;
|
|
2147
|
+
if (!context.auth) {
|
|
2148
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2149
|
+
}
|
|
2150
|
+
if (tag.userId === null) {
|
|
2151
|
+
throw new import_errors.ValidationError("the badge has no employee any more \u2014 give it one first");
|
|
2152
|
+
}
|
|
2153
|
+
const timestamp = now();
|
|
2154
|
+
const expiresAt = timestamp + ttlDays * 86400;
|
|
2155
|
+
const signature = (0, import_rfid.signTag)(requireHmacSecret(), tag.uid, tag.userId, expiresAt);
|
|
2156
|
+
const updated = rfid.update({
|
|
2157
|
+
id: tag.id,
|
|
2158
|
+
userId: tag.userId,
|
|
2159
|
+
expiresAt,
|
|
2160
|
+
signature,
|
|
2161
|
+
activate: true,
|
|
2162
|
+
...label === void 0 ? {} : { label },
|
|
2163
|
+
actorId: context.auth.user.id,
|
|
2164
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2165
|
+
now: timestamp
|
|
2166
|
+
});
|
|
2167
|
+
if (!updated) {
|
|
2168
|
+
throw new import_errors.NotFoundError(`tag ${tag.id} not found`);
|
|
2169
|
+
}
|
|
2170
|
+
return { tag: updated, token: (0, import_rfid.buildTagToken)(updated.uid, tag.userId, expiresAt, signature) };
|
|
2171
|
+
};
|
|
2172
|
+
route(
|
|
2173
|
+
"GET",
|
|
2174
|
+
"/rfid/tags",
|
|
2175
|
+
{ permission: "rfid.manage" },
|
|
2176
|
+
() => (0, import_router.json)(200, { tags: rfid.list({ includeInactive: true }) })
|
|
2177
|
+
);
|
|
2178
|
+
route("POST", "/rfid/tags", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2179
|
+
var _a2, _b2, _c2, _d2;
|
|
2180
|
+
if (!context.auth) {
|
|
2181
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2182
|
+
}
|
|
2183
|
+
const secret = requireHmacSecret();
|
|
2184
|
+
const body = context.jsonBody();
|
|
2185
|
+
const target = Number((_a2 = body.userId) != null ? _a2 : context.auth.user.id);
|
|
2186
|
+
if (!Number.isInteger(target) || !users.findById(target)) {
|
|
2187
|
+
throw new import_errors.ValidationError("userId must reference an existing employee");
|
|
2188
|
+
}
|
|
2189
|
+
const ttlDays = (_b2 = optionalNumber(body, "ttlDays")) != null ? _b2 : 365;
|
|
2190
|
+
if (!Number.isInteger(ttlDays) || ttlDays <= 0) {
|
|
2191
|
+
throw new import_errors.ValidationError(`ttlDays must be a positive whole number (got ${ttlDays})`);
|
|
2192
|
+
}
|
|
2193
|
+
const timestamp = now();
|
|
2194
|
+
const expiresAt = timestamp + ttlDays * 86400;
|
|
2195
|
+
const uid = (_c2 = optionalString(body, "uid")) != null ? _c2 : (0, import_rfid.newTagUid)();
|
|
2196
|
+
const signature = (0, import_rfid.signTag)(secret, uid, target, expiresAt);
|
|
2197
|
+
const tag = rfid.create({
|
|
2198
|
+
uid,
|
|
2199
|
+
userId: target,
|
|
2200
|
+
signature,
|
|
2201
|
+
label: optionalString(body, "label"),
|
|
2202
|
+
expiresAt,
|
|
2203
|
+
actorId: context.auth.user.id,
|
|
2204
|
+
actorIp: (_d2 = context.request.remoteAddress) != null ? _d2 : null,
|
|
2205
|
+
now: timestamp
|
|
2206
|
+
});
|
|
2207
|
+
const token = (0, import_rfid.buildTagToken)(uid, target, expiresAt, signature);
|
|
2208
|
+
return (0, import_router.json)(201, { tag, token, url: tagLink(context, token) }, { location: `/rfid/tags/${tag.id}` });
|
|
2209
|
+
});
|
|
2210
|
+
route("DELETE", "/rfid/tags/:id", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2211
|
+
var _a2;
|
|
2212
|
+
if (!context.auth) {
|
|
2213
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2214
|
+
}
|
|
2215
|
+
const revoked = rfid.revoke({
|
|
2216
|
+
id: numberParam(context, "id"),
|
|
2217
|
+
actorId: context.auth.user.id,
|
|
2218
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2219
|
+
now: now()
|
|
2220
|
+
});
|
|
2221
|
+
if (!revoked) {
|
|
2222
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found or already revoked`);
|
|
2223
|
+
}
|
|
2224
|
+
return (0, import_router.noContent)();
|
|
2225
|
+
});
|
|
2226
|
+
route("DELETE", "/rfid/tags/:id/permanent", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2227
|
+
var _a2;
|
|
2228
|
+
if (!context.auth) {
|
|
2229
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2230
|
+
}
|
|
2231
|
+
const removed = rfid.remove({
|
|
2232
|
+
id: numberParam(context, "id"),
|
|
2233
|
+
actorId: context.auth.user.id,
|
|
2234
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2235
|
+
now: now()
|
|
2236
|
+
});
|
|
2237
|
+
if (!removed) {
|
|
2238
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2239
|
+
}
|
|
2240
|
+
return (0, import_router.noContent)();
|
|
2241
|
+
});
|
|
2242
|
+
route("PATCH", "/rfid/tags/:id", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2243
|
+
var _a2, _b2;
|
|
2244
|
+
if (!context.auth) {
|
|
2245
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2246
|
+
}
|
|
2247
|
+
const id = numberParam(context, "id");
|
|
2248
|
+
const body = context.jsonBody();
|
|
2249
|
+
const current = rfid.findById(id);
|
|
2250
|
+
if (!current) {
|
|
2251
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2252
|
+
}
|
|
2253
|
+
const userId = body.userId === void 0 ? current.userId : Number(body.userId);
|
|
2254
|
+
if (userId === null || !Number.isInteger(userId) || !users.findById(userId)) {
|
|
2255
|
+
throw new import_errors.ValidationError("userId must reference an existing employee");
|
|
2256
|
+
}
|
|
2257
|
+
const ttlDays = (_a2 = optionalNumber(body, "ttlDays")) != null ? _a2 : void 0;
|
|
2258
|
+
if (ttlDays !== void 0 && (!Number.isInteger(ttlDays) || ttlDays <= 0)) {
|
|
2259
|
+
throw new import_errors.ValidationError(`ttlDays must be a positive whole number (got ${ttlDays})`);
|
|
2260
|
+
}
|
|
2261
|
+
const label = body.label === void 0 ? void 0 : optionalString(body, "label");
|
|
2262
|
+
if (userId !== current.userId || ttlDays !== void 0) {
|
|
2263
|
+
const signed = reissueLink(context, { ...current, userId }, ttlDays != null ? ttlDays : 365, label);
|
|
2264
|
+
return (0, import_router.json)(200, { tag: signed.tag, token: signed.token, url: tagLink(context, signed.token) });
|
|
2265
|
+
}
|
|
2266
|
+
const changed = rfid.update({
|
|
2267
|
+
id,
|
|
2268
|
+
userId,
|
|
2269
|
+
...label === void 0 ? {} : { label },
|
|
2270
|
+
actorId: context.auth.user.id,
|
|
2271
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
2272
|
+
now: now()
|
|
2273
|
+
});
|
|
2274
|
+
if (!changed) {
|
|
2275
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2276
|
+
}
|
|
2277
|
+
return (0, import_router.json)(200, { tag: changed });
|
|
2278
|
+
});
|
|
2279
|
+
route("POST", "/rfid/tags/:id/link", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2280
|
+
var _a2;
|
|
2281
|
+
if (!context.auth) {
|
|
2282
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2283
|
+
}
|
|
2284
|
+
const tag = rfid.findById(numberParam(context, "id"));
|
|
2285
|
+
if (!tag) {
|
|
2286
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2287
|
+
}
|
|
2288
|
+
const ttlDays = (_a2 = optionalNumber(context.jsonBody(), "ttlDays")) != null ? _a2 : 365;
|
|
2289
|
+
if (!Number.isInteger(ttlDays) || ttlDays <= 0) {
|
|
2290
|
+
throw new import_errors.ValidationError(`ttlDays must be a positive whole number (got ${ttlDays})`);
|
|
2291
|
+
}
|
|
2292
|
+
const signed = reissueLink(context, tag, ttlDays);
|
|
2293
|
+
return (0, import_router.json)(200, { tag: signed.tag, token: signed.token, url: tagLink(context, signed.token) });
|
|
2294
|
+
});
|
|
2295
|
+
route(
|
|
2296
|
+
"POST",
|
|
2297
|
+
"/rfid/scan",
|
|
2298
|
+
{ public: true, csrf: false, rateLimit: { name: "rfid-scan", limit: 30, windowSeconds: 60 } },
|
|
2299
|
+
(context) => {
|
|
2300
|
+
var _a2, _b2, _c2;
|
|
2301
|
+
const secret = requireHmacSecret();
|
|
2302
|
+
const body = context.jsonBody();
|
|
2303
|
+
const parsed = (0, import_rfid.parseTagToken)(requireString(body, "token"));
|
|
2304
|
+
if (!parsed) {
|
|
2305
|
+
throw new import_errors.ValidationError("token is not valid");
|
|
2306
|
+
}
|
|
2307
|
+
const expected = (0, import_rfid.signTag)(secret, parsed.uid, parsed.userId, parsed.expiresAt);
|
|
2308
|
+
if (!(0, import_rfid.sameSignature)(expected, parsed.signature)) {
|
|
2309
|
+
throw (0, import_problem.problem)(401, "invalid_credentials", "the tag is not valid any more");
|
|
2310
|
+
}
|
|
2311
|
+
const tag = rfid.verify({
|
|
2312
|
+
uid: parsed.uid,
|
|
2313
|
+
userId: parsed.userId,
|
|
2314
|
+
signature: parsed.signature,
|
|
2315
|
+
now: now()
|
|
2316
|
+
});
|
|
2317
|
+
if (!tag) {
|
|
2318
|
+
throw (0, import_problem.problem)(401, "invalid_credentials", "the tag is not valid any more");
|
|
2319
|
+
}
|
|
2320
|
+
const user = users.findById((_a2 = tag.userId) != null ? _a2 : 0);
|
|
2321
|
+
if (!user || !user.isActive) {
|
|
2322
|
+
throw (0, import_problem.problem)(401, "user_inactive", "the owner of the tag is not active");
|
|
2323
|
+
}
|
|
2324
|
+
const timestamp = now();
|
|
2325
|
+
const stored = entries.insert({
|
|
2326
|
+
userId: user.id,
|
|
2327
|
+
tsUtc: (_b2 = optionalNumber(body, "tsUtc")) != null ? _b2 : timestamp,
|
|
2328
|
+
timeZone: user.timezone,
|
|
2329
|
+
source: "nfc",
|
|
2330
|
+
direction: optionalDirection(body),
|
|
2331
|
+
note: optionalString(body, "note"),
|
|
2332
|
+
actorId: user.id,
|
|
2333
|
+
actorIp: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
|
|
2334
|
+
now: timestamp
|
|
2335
|
+
});
|
|
2336
|
+
rfid.touch({ id: tag.id, now: timestamp });
|
|
2337
|
+
const day = aggregation.recalculateDay(user.id, stored.entry.localDate, { now: timestamp });
|
|
2338
|
+
emit({
|
|
2339
|
+
type: "rfid.scan",
|
|
2340
|
+
userId: user.id,
|
|
2341
|
+
data: {
|
|
2342
|
+
entryId: stored.entry.id,
|
|
2343
|
+
localDate: stored.entry.localDate,
|
|
2344
|
+
tagId: tag.id,
|
|
2345
|
+
uid: tag.uid
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
return (0, import_router.json)(201, {
|
|
2349
|
+
user: { id: user.id, displayName: user.displayName },
|
|
2350
|
+
entry: {
|
|
2351
|
+
id: stored.entry.id,
|
|
2352
|
+
tsUtc: stored.entry.tsUtc,
|
|
2353
|
+
localDate: stored.entry.localDate
|
|
2354
|
+
},
|
|
2355
|
+
day: {
|
|
2356
|
+
workedMin: day.workedMin,
|
|
2357
|
+
targetMin: day.targetMin,
|
|
2358
|
+
balanceMin: day.balanceMin,
|
|
2359
|
+
hasOpenEntry: day.hasOpenEntry
|
|
2360
|
+
}
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
);
|
|
2364
|
+
const monthlyReport = (context) => {
|
|
2365
|
+
var _a2;
|
|
2366
|
+
const userId = scopeUser(context);
|
|
2367
|
+
const user = users.findById(userId);
|
|
2368
|
+
if (!user) {
|
|
2369
|
+
throw new import_errors.NotFoundError(`user ${userId} not found`);
|
|
2370
|
+
}
|
|
2371
|
+
const year = numberQuery(context, "year");
|
|
2372
|
+
const month = numberQuery(context, "month");
|
|
2373
|
+
if (month < 1 || month > 12) {
|
|
2374
|
+
throw new import_errors.ValidationError(`month must be between 1 and 12 (got ${month})`);
|
|
2375
|
+
}
|
|
2376
|
+
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
|
2377
|
+
const first = `${prefix}-01`;
|
|
2378
|
+
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
2379
|
+
const last = `${prefix}-${String(lastDay).padStart(2, "0")}`;
|
|
2380
|
+
const timestamp = now();
|
|
2381
|
+
aggregation.recalculateRange(userId, first, last, { now: timestamp });
|
|
2382
|
+
const { labels, language } = (0, import_labels.reportLabels)(user.locale);
|
|
2383
|
+
return {
|
|
2384
|
+
year,
|
|
2385
|
+
month,
|
|
2386
|
+
login: user.login,
|
|
2387
|
+
input: {
|
|
2388
|
+
user: { displayName: user.displayName, login: user.login, timezone: user.timezone },
|
|
2389
|
+
labels,
|
|
2390
|
+
language,
|
|
2391
|
+
locale: user.locale || "en",
|
|
2392
|
+
year,
|
|
2393
|
+
month,
|
|
2394
|
+
days: aggregation.days(userId, first, last),
|
|
2395
|
+
absences: absences.withTypesInRange(userId, first, last).map((absence) => ({
|
|
2396
|
+
typeCode: absence.typeCode,
|
|
2397
|
+
typeName: absence.typeName,
|
|
2398
|
+
dateFrom: absence.dateFrom,
|
|
2399
|
+
dateTo: absence.dateTo,
|
|
2400
|
+
dayPortion: absence.dayPortion,
|
|
2401
|
+
hours: absence.hours
|
|
2402
|
+
})),
|
|
2403
|
+
generatedAt: timestamp,
|
|
2404
|
+
generator: `time-tracker ${(_a2 = deps.version) != null ? _a2 : ""}`.trim()
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
};
|
|
2408
|
+
route(
|
|
2409
|
+
"GET",
|
|
2410
|
+
"/reports/csv",
|
|
2411
|
+
{ permission: "report.view_own", rateLimit: { name: "export", limit: 20, windowSeconds: 60 } },
|
|
2412
|
+
(context) => {
|
|
2413
|
+
const { year, month, login } = monthlyReport(context);
|
|
2414
|
+
const author = users.findById(scopeUser(context));
|
|
2415
|
+
if (!author) {
|
|
2416
|
+
throw new import_errors.NotFoundError("user not found");
|
|
2417
|
+
}
|
|
2418
|
+
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
|
2419
|
+
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
2420
|
+
const csv = buildEntriesCsv(
|
|
2421
|
+
entries.listByRange(author.id, `${prefix}-01`, `${prefix}-${String(lastDay).padStart(2, "0")}`),
|
|
2422
|
+
author.timezone
|
|
2423
|
+
);
|
|
2424
|
+
return (0, import_router.binary)(200, csv, "text/csv; charset=utf-8", {
|
|
2425
|
+
"content-disposition": `attachment; filename="${(0, import_types.reportFileName)(login, year, month, "csv")}"`,
|
|
2426
|
+
"cache-control": "no-store"
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
);
|
|
2430
|
+
route(
|
|
2431
|
+
"GET",
|
|
2432
|
+
"/reports/xls",
|
|
2433
|
+
{ permission: "report.view_own", rateLimit: { name: "export", limit: 20, windowSeconds: 60 } },
|
|
2434
|
+
async (context) => {
|
|
2435
|
+
const { input, year, month, login } = monthlyReport(context);
|
|
2436
|
+
const workbook = await (0, import_xls.buildMonthReport)(input);
|
|
2437
|
+
return (0, import_router.binary)(200, workbook, XLSX_CONTENT_TYPE, {
|
|
2438
|
+
"content-disposition": `attachment; filename="${(0, import_types.reportFileName)(login, year, month, "xlsx")}"`,
|
|
2439
|
+
"cache-control": "no-store"
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
);
|
|
2443
|
+
route(
|
|
2444
|
+
"GET",
|
|
2445
|
+
"/reports/pdf",
|
|
2446
|
+
{ permission: "report.view_own", rateLimit: { name: "export", limit: 20, windowSeconds: 60 } },
|
|
2447
|
+
async (context) => {
|
|
2448
|
+
var _a2;
|
|
2449
|
+
const { input, year, month, login } = monthlyReport(context);
|
|
2450
|
+
let statement;
|
|
2451
|
+
try {
|
|
2452
|
+
statement = await (0, import_pdf.buildMonthStatement)({
|
|
2453
|
+
...input,
|
|
2454
|
+
// a Unicode font is only needed for scripts the built-in fonts cannot show (see the renderer)
|
|
2455
|
+
fontPath: (_a2 = settings.get("report_font_path")) != null ? _a2 : ""
|
|
2456
|
+
});
|
|
2457
|
+
} catch (error) {
|
|
2458
|
+
if (error instanceof import_errors.ValidationError && error.message.includes("Unicode font")) {
|
|
2459
|
+
throw (0, import_problem.problem)(
|
|
2460
|
+
422,
|
|
2461
|
+
"report_font_missing",
|
|
2462
|
+
"this language needs a Unicode font: set report_font_path to a .ttf/.otf file that covers the script"
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
throw error;
|
|
2466
|
+
}
|
|
2467
|
+
return (0, import_router.binary)(200, statement, PDF_CONTENT_TYPE, {
|
|
2468
|
+
"content-disposition": `attachment; filename="${(0, import_types.reportFileName)(login, year, month, "pdf")}"`,
|
|
2469
|
+
"cache-control": "no-store"
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
);
|
|
2473
|
+
if (deps.backup) {
|
|
2474
|
+
const backup = deps.backup;
|
|
2475
|
+
route(
|
|
2476
|
+
"GET",
|
|
2477
|
+
"/backup",
|
|
2478
|
+
{ permission: "backup.run" },
|
|
2479
|
+
() => (0, import_router.json)(200, {
|
|
2480
|
+
retentionDays: settings.getNumber("backup_retention_days", 30),
|
|
2481
|
+
backups: backup.list(),
|
|
2482
|
+
// a restore that waits for the next start, so the screen can say so
|
|
2483
|
+
pending: backup.pending()
|
|
2484
|
+
})
|
|
2485
|
+
);
|
|
2486
|
+
route("GET", "/backup/:name", { permission: "backup.run" }, (context) => {
|
|
2487
|
+
const wanted = context.params.name;
|
|
2488
|
+
const file = backup.list().find((entry) => entry.name === wanted);
|
|
2489
|
+
if (!file) {
|
|
2490
|
+
throw new import_errors.NotFoundError(`backup ${wanted} not found`);
|
|
2491
|
+
}
|
|
2492
|
+
return (0, import_router.binary)(200, fs.readFileSync(file.file), "application/vnd.sqlite3", {
|
|
2493
|
+
"content-disposition": `attachment; filename="${file.name}"`,
|
|
2494
|
+
"cache-control": "no-store"
|
|
2495
|
+
});
|
|
2496
|
+
});
|
|
2497
|
+
route(
|
|
2498
|
+
"POST",
|
|
2499
|
+
"/backup",
|
|
2500
|
+
{ permission: "backup.run", csrf: true, rateLimit: { name: "backup", limit: 10, windowSeconds: 60 } },
|
|
2501
|
+
(context) => {
|
|
2502
|
+
var _a2, _b2;
|
|
2503
|
+
const created = backup.create({
|
|
2504
|
+
actorId: (_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : null,
|
|
2505
|
+
reason: "api"
|
|
2506
|
+
});
|
|
2507
|
+
emit({ type: "backup.create", userId: null, data: { backup: created.backup.name } });
|
|
2508
|
+
return (0, import_router.json)(201, { backup: created.backup, removed: created.removed });
|
|
2509
|
+
}
|
|
2510
|
+
);
|
|
2511
|
+
route(
|
|
2512
|
+
"DELETE",
|
|
2513
|
+
"/backup/:name",
|
|
2514
|
+
{ permission: "backup.run", csrf: true, rateLimit: { name: "backup", limit: 10, windowSeconds: 60 } },
|
|
2515
|
+
(context) => {
|
|
2516
|
+
var _a2, _b2;
|
|
2517
|
+
try {
|
|
2518
|
+
backup.remove(context.params.name, { actorId: (_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : null, reason: "api" });
|
|
2519
|
+
} catch (error) {
|
|
2520
|
+
if (error instanceof import_errors.ValidationError) {
|
|
2521
|
+
throw new import_errors.NotFoundError(error.message);
|
|
2522
|
+
}
|
|
2523
|
+
throw error;
|
|
2524
|
+
}
|
|
2525
|
+
return (0, import_router.noContent)(204);
|
|
2526
|
+
}
|
|
2527
|
+
);
|
|
2528
|
+
route(
|
|
2529
|
+
"POST",
|
|
2530
|
+
"/backup/restore",
|
|
2531
|
+
{
|
|
2532
|
+
permission: "backup.run",
|
|
2533
|
+
csrf: true,
|
|
2534
|
+
rateLimit: { name: "backup", limit: 10, windowSeconds: 60 },
|
|
2535
|
+
maxBodyBytes: MAX_BACKUP_UPLOAD_BYTES
|
|
2536
|
+
},
|
|
2537
|
+
(context) => {
|
|
2538
|
+
var _a2, _b2, _c2, _d2, _e, _f, _g;
|
|
2539
|
+
const upload = ((_a2 = context.header("content-type")) != null ? _a2 : "").toLowerCase().startsWith("application/octet-stream");
|
|
2540
|
+
try {
|
|
2541
|
+
if (upload) {
|
|
2542
|
+
const pending2 = backup.queueRestore(context.rawBody(), {
|
|
2543
|
+
name: (_b2 = context.query("name")) != null ? _b2 : void 0,
|
|
2544
|
+
actorId: (_d2 = (_c2 = context.auth) == null ? void 0 : _c2.user.id) != null ? _d2 : null,
|
|
2545
|
+
reason: (_e = context.query("reason")) != null ? _e : void 0
|
|
2546
|
+
});
|
|
2547
|
+
return (0, import_router.json)(201, { pending: pending2 });
|
|
2548
|
+
}
|
|
2549
|
+
const body = context.jsonBody();
|
|
2550
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
2551
|
+
if (name === "") {
|
|
2552
|
+
throw (0, import_problem.problem)(400, "backup_invalid", "the request needs `name` with one of the listed backups");
|
|
2553
|
+
}
|
|
2554
|
+
const pending = backup.queueExistingBackup(name, {
|
|
2555
|
+
actorId: (_g = (_f = context.auth) == null ? void 0 : _f.user.id) != null ? _g : null,
|
|
2556
|
+
reason: typeof body.reason === "string" ? body.reason : void 0
|
|
2557
|
+
});
|
|
2558
|
+
return (0, import_router.json)(201, { pending });
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
if (error instanceof import_errors.ValidationError) {
|
|
2561
|
+
throw (0, import_problem.problem)(400, "backup_invalid", error.message);
|
|
2562
|
+
}
|
|
2563
|
+
throw error;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
route(
|
|
2569
|
+
"GET",
|
|
2570
|
+
"/health",
|
|
2571
|
+
{ public: true },
|
|
2572
|
+
() => {
|
|
2573
|
+
var _a2;
|
|
2574
|
+
return (0, import_router.json)(200, {
|
|
2575
|
+
status: "ok",
|
|
2576
|
+
holidayCountry: (_a2 = settings.get("holiday_country")) != null ? _a2 : "DE",
|
|
2577
|
+
time: now()
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
);
|
|
2581
|
+
route("GET", "/routes", { public: true }, () => (0, import_router.json)(200, { routes: registered }));
|
|
2582
|
+
return { router, routes: () => registered.slice(), events };
|
|
2583
|
+
}
|
|
2584
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2585
|
+
0 && (module.exports = {
|
|
2586
|
+
MAX_BACKUP_UPLOAD_BYTES,
|
|
2587
|
+
createApi
|
|
2588
|
+
});
|
|
2589
|
+
//# sourceMappingURL=api.js.map
|