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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +386 -0
  3. package/admin/i18n/de.json +34 -0
  4. package/admin/i18n/en.json +1 -0
  5. package/admin/i18n/es.json +34 -0
  6. package/admin/i18n/fr.json +34 -0
  7. package/admin/i18n/it.json +34 -0
  8. package/admin/i18n/nl.json +34 -0
  9. package/admin/i18n/pl.json +34 -0
  10. package/admin/i18n/pt.json +34 -0
  11. package/admin/i18n/ru.json +34 -0
  12. package/admin/i18n/uk.json +34 -0
  13. package/admin/i18n/zh-cn.json +34 -0
  14. package/admin/jsonConfig.json +266 -0
  15. package/admin/time-tracker.png +0 -0
  16. package/build/lib/adapter/automation.js +100 -0
  17. package/build/lib/adapter/automation.js.map +7 -0
  18. package/build/lib/adapter/commands.js +160 -0
  19. package/build/lib/adapter/commands.js.map +7 -0
  20. package/build/lib/adapter/messages.js +207 -0
  21. package/build/lib/adapter/messages.js.map +7 -0
  22. package/build/lib/adapter/presence.js +129 -0
  23. package/build/lib/adapter/presence.js.map +7 -0
  24. package/build/lib/adapter/states.js +330 -0
  25. package/build/lib/adapter/states.js.map +7 -0
  26. package/build/lib/adapter/triggers.js +92 -0
  27. package/build/lib/adapter/triggers.js.map +7 -0
  28. package/build/lib/db/database.js +96 -0
  29. package/build/lib/db/database.js.map +7 -0
  30. package/build/lib/db/migrations.js +603 -0
  31. package/build/lib/db/migrations.js.map +7 -0
  32. package/build/lib/db/repositories/absences.js +432 -0
  33. package/build/lib/db/repositories/absences.js.map +7 -0
  34. package/build/lib/db/repositories/audit.js +90 -0
  35. package/build/lib/db/repositories/audit.js.map +7 -0
  36. package/build/lib/db/repositories/automations.js +326 -0
  37. package/build/lib/db/repositories/automations.js.map +7 -0
  38. package/build/lib/db/repositories/entries.js +335 -0
  39. package/build/lib/db/repositories/entries.js.map +7 -0
  40. package/build/lib/db/repositories/holidays.js +156 -0
  41. package/build/lib/db/repositories/holidays.js.map +7 -0
  42. package/build/lib/db/repositories/payouts.js +221 -0
  43. package/build/lib/db/repositories/payouts.js.map +7 -0
  44. package/build/lib/db/repositories/rfid.js +264 -0
  45. package/build/lib/db/repositories/rfid.js.map +7 -0
  46. package/build/lib/db/repositories/rules.js +356 -0
  47. package/build/lib/db/repositories/rules.js.map +7 -0
  48. package/build/lib/db/repositories/settings.js +91 -0
  49. package/build/lib/db/repositories/settings.js.map +7 -0
  50. package/build/lib/db/repositories/terminals.js +243 -0
  51. package/build/lib/db/repositories/terminals.js.map +7 -0
  52. package/build/lib/db/repositories/triggers.js +240 -0
  53. package/build/lib/db/repositories/triggers.js.map +7 -0
  54. package/build/lib/db/repositories/users.js +491 -0
  55. package/build/lib/db/repositories/users.js.map +7 -0
  56. package/build/lib/db/seed.js +180 -0
  57. package/build/lib/db/seed.js.map +7 -0
  58. package/build/lib/domain/avatar.js +63 -0
  59. package/build/lib/domain/avatar.js.map +7 -0
  60. package/build/lib/domain/breaks.js +70 -0
  61. package/build/lib/domain/breaks.js.map +7 -0
  62. package/build/lib/domain/calculation.js +150 -0
  63. package/build/lib/domain/calculation.js.map +7 -0
  64. package/build/lib/domain/holidays.js +113 -0
  65. package/build/lib/domain/holidays.js.map +7 -0
  66. package/build/lib/domain/punch.js +99 -0
  67. package/build/lib/domain/punch.js.map +7 -0
  68. package/build/lib/domain/target.js +89 -0
  69. package/build/lib/domain/target.js.map +7 -0
  70. package/build/lib/errors.js +56 -0
  71. package/build/lib/errors.js.map +7 -0
  72. package/build/lib/reports/labels.js +358 -0
  73. package/build/lib/reports/labels.js.map +7 -0
  74. package/build/lib/reports/pdf.js +308 -0
  75. package/build/lib/reports/pdf.js.map +7 -0
  76. package/build/lib/reports/types.js +32 -0
  77. package/build/lib/reports/types.js.map +7 -0
  78. package/build/lib/reports/xls.js +211 -0
  79. package/build/lib/reports/xls.js.map +7 -0
  80. package/build/lib/services/aggregation.js +495 -0
  81. package/build/lib/services/aggregation.js.map +7 -0
  82. package/build/lib/services/auth.js +381 -0
  83. package/build/lib/services/auth.js.map +7 -0
  84. package/build/lib/services/backup.js +372 -0
  85. package/build/lib/services/backup.js.map +7 -0
  86. package/build/lib/services/closing.js +106 -0
  87. package/build/lib/services/closing.js.map +7 -0
  88. package/build/lib/services/sessionSecret.js +100 -0
  89. package/build/lib/services/sessionSecret.js.map +7 -0
  90. package/build/lib/services/sync.js +278 -0
  91. package/build/lib/services/sync.js.map +7 -0
  92. package/build/lib/util/time.js +125 -0
  93. package/build/lib/util/time.js.map +7 -0
  94. package/build/lib/web/api.js +2589 -0
  95. package/build/lib/web/api.js.map +7 -0
  96. package/build/lib/web/cookies.js +80 -0
  97. package/build/lib/web/cookies.js.map +7 -0
  98. package/build/lib/web/events.js +50 -0
  99. package/build/lib/web/events.js.map +7 -0
  100. package/build/lib/web/pin-guard.js +86 -0
  101. package/build/lib/web/pin-guard.js.map +7 -0
  102. package/build/lib/web/problem.js +129 -0
  103. package/build/lib/web/problem.js.map +7 -0
  104. package/build/lib/web/rate-limit.js +65 -0
  105. package/build/lib/web/rate-limit.js.map +7 -0
  106. package/build/lib/web/router.js +274 -0
  107. package/build/lib/web/router.js.map +7 -0
  108. package/build/lib/web/server.js +189 -0
  109. package/build/lib/web/server.js.map +7 -0
  110. package/build/lib/web/static.js +151 -0
  111. package/build/lib/web/static.js.map +7 -0
  112. package/build/lib/web/stream.js +135 -0
  113. package/build/lib/web/stream.js.map +7 -0
  114. package/build/main.js +986 -0
  115. package/build/main.js.map +7 -0
  116. package/io-package.json +221 -0
  117. package/package.json +131 -0
  118. package/src-pwa/src/i18n/de.json +359 -0
  119. package/src-pwa/src/i18n/en.json +359 -0
  120. package/src-pwa/src/i18n/es.json +359 -0
  121. package/src-pwa/src/i18n/fr.json +359 -0
  122. package/src-pwa/src/i18n/index.ts +96 -0
  123. package/src-pwa/src/i18n/it.json +359 -0
  124. package/src-pwa/src/i18n/nl.json +359 -0
  125. package/src-pwa/src/i18n/pl.json +359 -0
  126. package/src-pwa/src/i18n/pt.json +359 -0
  127. package/src-pwa/src/i18n/ru.json +359 -0
  128. package/src-pwa/src/i18n/uk.json +359 -0
  129. package/src-pwa/src/i18n/zh-cn.json +359 -0
  130. package/www/assets/index-QI3wrEvW.js +119 -0
  131. package/www/assets/index-QI3wrEvW.js.map +1 -0
  132. package/www/assets/workbox-window.prod.es5-Bd17z0YL.js +3 -0
  133. package/www/assets/workbox-window.prod.es5-Bd17z0YL.js.map +1 -0
  134. package/www/favicon.svg +16 -0
  135. package/www/icon-192.png +0 -0
  136. package/www/icon-512.png +0 -0
  137. package/www/index.html +38 -0
  138. package/www/manifest.webmanifest +1 -0
  139. package/www/person.png +0 -0
  140. package/www/sw.js +3 -0
  141. package/www/sw.js.map +1 -0
  142. package/www/workbox-9c191d2f.js +3 -0
  143. package/www/workbox-9c191d2f.js.map +1 -0
@@ -0,0 +1,372 @@
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 backup_exports = {};
30
+ __export(backup_exports, {
31
+ RESTORE_PENDING_FILE: () => RESTORE_PENDING_FILE,
32
+ RESTORE_PENDING_INFO: () => RESTORE_PENDING_INFO,
33
+ applyPendingRestore: () => applyPendingRestore,
34
+ createBackupService: () => createBackupService,
35
+ pendingRestoreInfoPath: () => pendingRestoreInfoPath,
36
+ pendingRestorePath: () => pendingRestorePath,
37
+ readPendingRestore: () => readPendingRestore,
38
+ recordRestore: () => recordRestore,
39
+ swapDatabaseFile: () => swapDatabaseFile,
40
+ verifyBackupFile: () => verifyBackupFile
41
+ });
42
+ module.exports = __toCommonJS(backup_exports);
43
+ var fs = __toESM(require("node:fs"));
44
+ var path = __toESM(require("node:path"));
45
+ var import_better_sqlite3 = __toESM(require("better-sqlite3"));
46
+ var import_audit = require("../db/repositories/audit");
47
+ var import_errors = require("../errors");
48
+ const DEFAULT_PREFIX = "time-tracker-";
49
+ const RESTORE_PENDING_FILE = "restore-pending.sqlite";
50
+ const RESTORE_PENDING_INFO = "restore-pending.json";
51
+ function verifyBackupFile(file, prefix = DEFAULT_PREFIX) {
52
+ var _a;
53
+ if (!fs.existsSync(file)) {
54
+ throw new import_errors.ValidationError(`backup ${path.basename(file)} does not exist`);
55
+ }
56
+ const sizeBytes = fs.statSync(file).size;
57
+ let check;
58
+ try {
59
+ check = new import_better_sqlite3.default(file, { readonly: true, fileMustExist: true });
60
+ } catch (error) {
61
+ throw new import_errors.ValidationError(
62
+ `backup ${path.basename(file)} is not a readable database (${error.message})`
63
+ );
64
+ }
65
+ const name = path.basename(file);
66
+ try {
67
+ const integrity = check.pragma("integrity_check", { simple: true });
68
+ if (integrity !== "ok") {
69
+ throw new import_errors.ValidationError(`backup ${name} is damaged: ${String(integrity)}`);
70
+ }
71
+ const version = check.prepare("SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations").get();
72
+ if (!version) {
73
+ throw new import_errors.ValidationError(`backup ${name} has no schema information`);
74
+ }
75
+ const users = check.prepare("SELECT COUNT(*) AS count FROM users").get();
76
+ const entries = check.prepare("SELECT COUNT(*) AS count FROM time_entries").get();
77
+ return {
78
+ name,
79
+ file,
80
+ sizeBytes,
81
+ createdAt: (_a = createdAtOf(name, prefix)) != null ? _a : Math.floor(Date.now() / 1e3),
82
+ schemaVersion: version.version,
83
+ users: users.count,
84
+ entries: entries.count
85
+ };
86
+ } catch (error) {
87
+ if (error instanceof import_errors.ValidationError) {
88
+ throw error;
89
+ }
90
+ const message = error.message;
91
+ throw new import_errors.ValidationError(
92
+ /not a database|notadb/i.test(message) ? `backup ${name} is not a readable database (${message})` : `backup ${name} is not a backup of this adapter (${message})`
93
+ );
94
+ } finally {
95
+ check.close();
96
+ }
97
+ }
98
+ function swapDatabaseFile(dbFile, backupFile, now = () => Math.floor(Date.now() / 1e3)) {
99
+ if (!dbFile || dbFile === ":memory:") {
100
+ throw new import_errors.ValidationError("an in-memory database cannot be restored");
101
+ }
102
+ for (const suffix of ["-wal", "-shm"]) {
103
+ fs.rmSync(`${dbFile}${suffix}`, { force: true });
104
+ }
105
+ const previous = fs.existsSync(dbFile) ? `${dbFile}.before-restore-${stamp(now())}` : null;
106
+ if (previous) {
107
+ fs.renameSync(dbFile, previous);
108
+ }
109
+ fs.copyFileSync(backupFile, dbFile);
110
+ return previous;
111
+ }
112
+ function pendingRestorePath(dbFile) {
113
+ return path.join(path.dirname(dbFile), RESTORE_PENDING_FILE);
114
+ }
115
+ function pendingRestoreInfoPath(dbFile) {
116
+ return path.join(path.dirname(dbFile), RESTORE_PENDING_INFO);
117
+ }
118
+ function readPendingRestore(dbFile) {
119
+ const info = pendingRestoreInfoPath(dbFile);
120
+ if (!fs.existsSync(info) || !fs.existsSync(pendingRestorePath(dbFile))) {
121
+ return null;
122
+ }
123
+ try {
124
+ return JSON.parse(fs.readFileSync(info, "utf8"));
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+ function applyPendingRestore(dbFile, now = () => Math.floor(Date.now() / 1e3)) {
130
+ const pending = readPendingRestore(dbFile);
131
+ if (!pending) {
132
+ return null;
133
+ }
134
+ const file = pendingRestorePath(dbFile);
135
+ const restored = verifyBackupFile(file);
136
+ const previous = swapDatabaseFile(dbFile, file, now);
137
+ fs.rmSync(file, { force: true });
138
+ fs.rmSync(pendingRestoreInfoPath(dbFile), { force: true });
139
+ return { restored, previous, actorId: pending.actorId };
140
+ }
141
+ const DEFAULT_RETENTION_DAYS = 30;
142
+ function stamp(tsUtc) {
143
+ return new Date(tsUtc * 1e3).toISOString().slice(0, 19).replace(/:/g, "-");
144
+ }
145
+ function createdAtOf(name, prefix) {
146
+ if (!name.startsWith(prefix) || !name.endsWith(".sqlite")) {
147
+ return null;
148
+ }
149
+ const raw = name.slice(prefix.length, -".sqlite".length);
150
+ const iso = `${raw.slice(0, 11)}${raw.slice(11).replace(/-/g, ":")}Z`;
151
+ const parsed = Date.parse(iso);
152
+ return Number.isNaN(parsed) ? null : Math.floor(parsed / 1e3);
153
+ }
154
+ function createBackupService(deps) {
155
+ var _a, _b, _c;
156
+ const prefix = (_a = deps.prefix) != null ? _a : DEFAULT_PREFIX;
157
+ const retentionDays = (_b = deps.retentionDays) != null ? _b : DEFAULT_RETENTION_DAYS;
158
+ const now = (_c = deps.now) != null ? _c : (() => Math.floor(Date.now() / 1e3));
159
+ function list() {
160
+ let names;
161
+ try {
162
+ names = fs.readdirSync(deps.dir);
163
+ } catch {
164
+ return [];
165
+ }
166
+ const files = [];
167
+ for (const name of names) {
168
+ const createdAt = createdAtOf(name, prefix);
169
+ if (createdAt === null) {
170
+ continue;
171
+ }
172
+ const file = path.join(deps.dir, name);
173
+ try {
174
+ files.push({ name, file, sizeBytes: fs.statSync(file).size, createdAt });
175
+ } catch {
176
+ }
177
+ }
178
+ return files.sort((a, b) => b.createdAt - a.createdAt || b.name.localeCompare(a.name));
179
+ }
180
+ function rotate() {
181
+ const files = list();
182
+ const removed = [];
183
+ for (const file of files.slice(1)) {
184
+ const ageDays = (now() - file.createdAt) / 86400;
185
+ if (ageDays <= retentionDays) {
186
+ continue;
187
+ }
188
+ try {
189
+ fs.rmSync(file.file);
190
+ removed.push(file.name);
191
+ } catch {
192
+ }
193
+ }
194
+ return removed;
195
+ }
196
+ function verify(file) {
197
+ return verifyBackupFile(file, prefix);
198
+ }
199
+ function create(input = {}) {
200
+ var _a2, _b2;
201
+ fs.mkdirSync(deps.dir, { recursive: true });
202
+ const timestamp = now();
203
+ const name = `${prefix}${stamp(timestamp)}.sqlite`;
204
+ const target = path.join(deps.dir, name);
205
+ fs.rmSync(target, { force: true });
206
+ const partial = `${target}.part`;
207
+ try {
208
+ deps.db.prepare("VACUUM INTO ?").run(partial);
209
+ fs.renameSync(partial, target);
210
+ } catch (error) {
211
+ fs.rmSync(partial, { force: true });
212
+ throw error;
213
+ }
214
+ const backup = verify(target);
215
+ (0, import_audit.writeAuditLog)(deps.db, {
216
+ actorId: (_a2 = input.actorId) != null ? _a2 : null,
217
+ action: "backup.create",
218
+ entity: "backup",
219
+ entityId: name,
220
+ detail: {
221
+ sizeBytes: backup.sizeBytes,
222
+ schemaVersion: backup.schemaVersion,
223
+ users: backup.users,
224
+ entries: backup.entries,
225
+ ...input.reason ? { reason: input.reason } : {}
226
+ },
227
+ atUtc: timestamp
228
+ });
229
+ const removed = rotate();
230
+ if (removed.length > 0) {
231
+ (0, import_audit.writeAuditLog)(deps.db, {
232
+ actorId: (_b2 = input.actorId) != null ? _b2 : null,
233
+ action: "backup.rotate",
234
+ entity: "backup",
235
+ detail: { removed },
236
+ atUtc: timestamp
237
+ });
238
+ }
239
+ return { backup, removed };
240
+ }
241
+ function databaseFile() {
242
+ const target = deps.db.name;
243
+ if (!target || target === ":memory:") {
244
+ throw new import_errors.ValidationError("an in-memory database cannot be restored");
245
+ }
246
+ return target;
247
+ }
248
+ function restore(file) {
249
+ const restored = verify(file);
250
+ if (deps.db.open) {
251
+ throw new import_errors.ValidationError("the database must be closed before it can be restored");
252
+ }
253
+ return { restored, previous: swapDatabaseFile(databaseFile(), file, now) };
254
+ }
255
+ function queue(source, input) {
256
+ var _a2, _b2;
257
+ const dbFile = databaseFile();
258
+ const pendingFile = pendingRestorePath(dbFile);
259
+ fs.mkdirSync(path.dirname(pendingFile), { recursive: true });
260
+ const staged = `${pendingFile}.part`;
261
+ fs.rmSync(staged, { force: true });
262
+ if (typeof source === "string") {
263
+ fs.copyFileSync(source, staged);
264
+ } else {
265
+ fs.writeFileSync(staged, source);
266
+ }
267
+ let info;
268
+ try {
269
+ info = verify(staged);
270
+ } catch (error) {
271
+ fs.rmSync(staged, { force: true });
272
+ throw error;
273
+ }
274
+ fs.renameSync(staged, pendingFile);
275
+ const queuedAt = now();
276
+ const label = ((_a2 = input.name) != null ? _a2 : "").trim().replace(/[^A-Za-z0-9._-]/g, "_");
277
+ const pending2 = {
278
+ name: label || info.name,
279
+ actorId: (_b2 = input.actorId) != null ? _b2 : null,
280
+ queuedAt,
281
+ sizeBytes: info.sizeBytes,
282
+ users: info.users,
283
+ entries: info.entries
284
+ };
285
+ fs.writeFileSync(pendingRestoreInfoPath(dbFile), `${JSON.stringify(pending2, null, " ")}
286
+ `, "utf8");
287
+ (0, import_audit.writeAuditLog)(deps.db, {
288
+ actorId: pending2.actorId,
289
+ action: "backup.queue_restore",
290
+ entity: "backup",
291
+ entityId: pending2.name,
292
+ detail: {
293
+ sizeBytes: pending2.sizeBytes,
294
+ users: pending2.users,
295
+ entries: pending2.entries,
296
+ ...input.reason ? { reason: input.reason } : {}
297
+ },
298
+ atUtc: queuedAt
299
+ });
300
+ return pending2;
301
+ }
302
+ function queueRestore(upload, input = {}) {
303
+ if (upload.length === 0) {
304
+ throw new import_errors.ValidationError("the uploaded file is empty");
305
+ }
306
+ return queue(upload, input);
307
+ }
308
+ function queueExistingBackup(name, input = {}) {
309
+ const known = list().find((entry) => entry.name === name);
310
+ if (!known) {
311
+ throw new import_errors.ValidationError(`backup ${name} does not exist`);
312
+ }
313
+ return queue(known.file, { ...input, name });
314
+ }
315
+ function remove(name, input = {}) {
316
+ var _a2;
317
+ const known = list().find((entry) => entry.name === name);
318
+ if (!known) {
319
+ throw new import_errors.ValidationError(`backup ${name} does not exist`);
320
+ }
321
+ fs.rmSync(known.file, { force: true });
322
+ (0, import_audit.writeAuditLog)(deps.db, {
323
+ actorId: (_a2 = input.actorId) != null ? _a2 : null,
324
+ action: "backup.remove",
325
+ entity: "backup",
326
+ entityId: known.name,
327
+ detail: {
328
+ sizeBytes: known.sizeBytes,
329
+ ...input.reason ? { reason: input.reason } : {}
330
+ },
331
+ atUtc: now()
332
+ });
333
+ return known;
334
+ }
335
+ function pending() {
336
+ const target = deps.db.name;
337
+ if (!target || target === ":memory:") {
338
+ return null;
339
+ }
340
+ return readPendingRestore(target);
341
+ }
342
+ return { create, list, rotate, verify, restore, queueRestore, queueExistingBackup, remove, pending };
343
+ }
344
+ function recordRestore(db, info, actorId, atUtc) {
345
+ return (0, import_audit.writeAuditLog)(db, {
346
+ actorId,
347
+ action: "backup.restore",
348
+ entity: "backup",
349
+ entityId: info.name,
350
+ detail: {
351
+ sizeBytes: info.sizeBytes,
352
+ schemaVersion: info.schemaVersion,
353
+ users: info.users,
354
+ entries: info.entries
355
+ },
356
+ atUtc
357
+ });
358
+ }
359
+ // Annotate the CommonJS export names for ESM import in node:
360
+ 0 && (module.exports = {
361
+ RESTORE_PENDING_FILE,
362
+ RESTORE_PENDING_INFO,
363
+ applyPendingRestore,
364
+ createBackupService,
365
+ pendingRestoreInfoPath,
366
+ pendingRestorePath,
367
+ readPendingRestore,
368
+ recordRestore,
369
+ swapDatabaseFile,
370
+ verifyBackupFile
371
+ });
372
+ //# sourceMappingURL=backup.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/services/backup.ts"],
4
+ "sourcesContent": ["/**\n * Backup service: hot copies of the database, retention and a tested restore.\n *\n * A backup is taken with `VACUUM INTO` \u2014 SQLite writes a consistent, defragmented copy while the adapter keeps\n * working, so there is no need to stop anything for a daily backup. The file is written next to its final name\n * and renamed afterwards, so a half written copy never looks like a usable backup.\n *\n * Files are named `<prefix><YYYY-MM-DDTHH-MM-SS>.sqlite` in UTC; the name is the only place the time is kept,\n * which keeps the rotation independent of filesystem timestamps (they change when a file is copied).\n *\n * Retention deletes files older than `retentionDays` but always keeps the newest one \u2014 an instance with a very\n * long downtime must not lose its last usable backup.\n *\n * A restore requires a **closed** database: the running instance holds the file open, so the swap is an\n * administrative step (stop the adapter, restore, start it again). `verify` refuses a file that is not a\n * readable, consistent database of this schema, so a restore never silently replaces data with garbage.\n */\n\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport type { Db } from \"../db/database\";\nimport { writeAuditLog } from \"../db/repositories/audit\";\nimport { ValidationError } from \"../errors\";\n\n/** Data sources of the backup service. */\nexport interface BackupDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** Directory the backups are written to (created when missing) */\n\tdir: string;\n\t/** File name prefix, default `time-tracker-` */\n\tprefix?: string;\n\t/** Days a backup is kept, default 30; `0` keeps only the newest one */\n\tretentionDays?: number;\n\t/** Instant source, defaults to the system clock */\n\tnow?: () => number;\n}\n\n/** One backup file. */\nexport interface BackupFile {\n\t/** File name without directory */\n\tname: string;\n\t/** Absolute path */\n\tfile: string;\n\t/** Size in bytes */\n\tsizeBytes: number;\n\t/** Instant from the name, UTC epoch seconds */\n\tcreatedAt: number;\n}\n\n/** A checked backup. */\nexport interface BackupInfo extends BackupFile {\n\t/** Schema version stored in the file */\n\tschemaVersion: number;\n\t/** Number of employees in the file */\n\tusers: number;\n\t/** Number of punches in the file */\n\tentries: number;\n}\n\n/** Result of taking a backup. */\nexport interface CreateBackupResult {\n\t/** The created file */\n\tbackup: BackupInfo;\n\t/** Names of the files the retention removed */\n\tremoved: string[];\n}\n\n/** Result of a restore. */\nexport interface RestoreResult {\n\t/** The restored file */\n\trestored: BackupInfo;\n\t/** Path the previous database was moved to, `null` when there was none */\n\tprevious: string | null;\n}\n\n/** The backup service. */\nexport interface BackupService {\n\t/** Takes a backup and applies the retention */\n\tcreate(input?: { actorId?: number | null; reason?: string }): CreateBackupResult;\n\t/** Known backups, newest first */\n\tlist(): BackupFile[];\n\t/** Applies the retention and returns the removed file names */\n\trotate(): string[];\n\t/** Checks a file: readable, consistent, expected schema */\n\tverify(file: string): BackupInfo;\n\t/** Replaces the database file with a backup (the database must be closed) */\n\trestore(file: string): RestoreResult;\n\t/** Queues an uploaded backup for the next start (the file is checked before it is kept) */\n\tqueueRestore(\n\t\tupload: Buffer,\n\t\tinput?: { name?: string; actorId?: number | null; reason?: string },\n\t): PendingRestoreInfo;\n\t/** Queues one of the known backups for the next start */\n\tqueueExistingBackup(name: string, input?: { actorId?: number | null; reason?: string }): PendingRestoreInfo;\n\t/** Deletes one of the known backups */\n\tremove(name: string, input?: { actorId?: number | null; reason?: string }): BackupFile;\n\t/** The restore that waits for the next start, `null` when there is none */\n\tpending(): PendingRestoreInfo | null;\n}\n\n/** Default prefix of the backup files. */\nconst DEFAULT_PREFIX = \"time-tracker-\";\n\n/** Name of a backup that waits for the next start of the adapter. */\nexport const RESTORE_PENDING_FILE = \"restore-pending.sqlite\";\n\n/** Sidecar of a pending restore: who queued it and what the file contains. */\nexport const RESTORE_PENDING_INFO = \"restore-pending.json\";\n\n/** A restore that waits for the next adapter start. */\nexport interface PendingRestoreInfo {\n\t/** Name the uploader gave the file, used for the download */\n\tname: string;\n\t/** Who queued the restore, `null` for the system */\n\tactorId: number | null;\n\t/** Instant the restore was queued, UTC epoch seconds */\n\tqueuedAt: number;\n\t/** Size in bytes */\n\tsizeBytes: number;\n\t/** Number of employees in the file */\n\tusers: number;\n\t/** Number of punches in the file */\n\tentries: number;\n}\n\n/** Result of applying a queued restore at startup. */\nexport interface AppliedRestore {\n\t/** The file that became the database */\n\trestored: BackupInfo;\n\t/** Path the previous database was moved to, `null` when there was none */\n\tprevious: string | null;\n\t/** Who queued it, `null` for the system */\n\tactorId: number | null;\n}\n\n/**\n * Checks a database file: readable, consistent and of this schema.\n *\n * The file is opened read-only, so a check never touches it. A file that is not a database, is damaged or comes\n * from a different schema is refused with a message that says why.\n *\n * @param file - path of the file\n * @param prefix - file name prefix, used to read the time from the name\n * @returns information about the checked file\n */\nexport function verifyBackupFile(file: string, prefix: string = DEFAULT_PREFIX): BackupInfo {\n\tif (!fs.existsSync(file)) {\n\t\tthrow new ValidationError(`backup ${path.basename(file)} does not exist`);\n\t}\n\n\tconst sizeBytes = fs.statSync(file).size;\n\tlet check: Database.Database;\n\ttry {\n\t\tcheck = new Database(file, { readonly: true, fileMustExist: true });\n\t} catch (error) {\n\t\tthrow new ValidationError(\n\t\t\t`backup ${path.basename(file)} is not a readable database (${(error as Error).message})`,\n\t\t);\n\t}\n\n\tconst name = path.basename(file);\n\ttry {\n\t\t// SQLite opens a file lazily, so a file that is not a database only fails on the first read\n\t\tconst integrity = check.pragma(\"integrity_check\", { simple: true });\n\t\tif (integrity !== \"ok\") {\n\t\t\tthrow new ValidationError(`backup ${name} is damaged: ${String(integrity)}`);\n\t\t}\n\t\tconst version = check.prepare(\"SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations\").get() as\n\t\t\t{ version: number } | undefined;\n\t\tif (!version) {\n\t\t\tthrow new ValidationError(`backup ${name} has no schema information`);\n\t\t}\n\t\tconst users = check.prepare(\"SELECT COUNT(*) AS count FROM users\").get() as { count: number };\n\t\tconst entries = check.prepare(\"SELECT COUNT(*) AS count FROM time_entries\").get() as { count: number };\n\n\t\treturn {\n\t\t\tname,\n\t\t\tfile,\n\t\t\tsizeBytes,\n\t\t\tcreatedAt: createdAtOf(name, prefix) ?? Math.floor(Date.now() / 1000),\n\t\t\tschemaVersion: version.version,\n\t\t\tusers: users.count,\n\t\t\tentries: entries.count,\n\t\t};\n\t} catch (error) {\n\t\tif (error instanceof ValidationError) {\n\t\t\tthrow error;\n\t\t}\n\t\tconst message = (error as Error).message;\n\t\tthrow new ValidationError(\n\t\t\t/not a database|notadb/i.test(message)\n\t\t\t\t? `backup ${name} is not a readable database (${message})`\n\t\t\t\t: `backup ${name} is not a backup of this adapter (${message})`,\n\t\t);\n\t} finally {\n\t\tcheck.close();\n\t}\n}\n\n/**\n * Replaces a database file with a backup file.\n *\n * The caller closes the database first; a file that does not pass {@link verifyBackupFile} never touches the\n * database, and the previous file is kept next to it (`<database>.before-restore-<stamp>`).\n *\n * @param dbFile - path of the database\n * @param backupFile - path of the backup to apply\n * @param now - instant source, defaults to the system clock\n * @returns path the previous database was moved to, `null` when there was none\n */\nexport function swapDatabaseFile(\n\tdbFile: string,\n\tbackupFile: string,\n\tnow: () => number = () => Math.floor(Date.now() / 1000),\n): string | null {\n\tif (!dbFile || dbFile === \":memory:\") {\n\t\tthrow new ValidationError(\"an in-memory database cannot be restored\");\n\t}\n\n\t// the journal of the old database would shadow the restored file\n\tfor (const suffix of [\"-wal\", \"-shm\"]) {\n\t\tfs.rmSync(`${dbFile}${suffix}`, { force: true });\n\t}\n\n\tconst previous = fs.existsSync(dbFile) ? `${dbFile}.before-restore-${stamp(now())}` : null;\n\tif (previous) {\n\t\tfs.renameSync(dbFile, previous);\n\t}\n\tfs.copyFileSync(backupFile, dbFile);\n\treturn previous;\n}\n\n/**\n * Path of a queued restore next to the database.\n *\n * @param dbFile - path of the database\n * @returns path of the queued backup file\n */\nexport function pendingRestorePath(dbFile: string): string {\n\treturn path.join(path.dirname(dbFile), RESTORE_PENDING_FILE);\n}\n\n/**\n * Path of the sidecar that describes the queued restore.\n *\n * @param dbFile - path of the database\n * @returns path of the sidecar\n */\nexport function pendingRestoreInfoPath(dbFile: string): string {\n\treturn path.join(path.dirname(dbFile), RESTORE_PENDING_INFO);\n}\n\n/**\n * Reads the queued restore, if there is one.\n *\n * A sidecar without the file (or the other way round) counts as nothing to do: an incomplete pair must not stop\n * the adapter from starting.\n *\n * @param dbFile - path of the database\n * @returns description of the queued restore or `null`\n */\nexport function readPendingRestore(dbFile: string): PendingRestoreInfo | null {\n\tconst info = pendingRestoreInfoPath(dbFile);\n\tif (!fs.existsSync(info) || !fs.existsSync(pendingRestorePath(dbFile))) {\n\t\treturn null;\n\t}\n\ttry {\n\t\treturn JSON.parse(fs.readFileSync(info, \"utf8\")) as PendingRestoreInfo;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Applies a queued restore while the database is closed.\n *\n * This runs while the adapter starts: the file the administration uploaded becomes the database, the previous\n * file is kept next to it, and the queued files are removed afterwards. A file that fails the check is refused\n * and the queued files stay, so the administrator can hand in a working one.\n *\n * @param dbFile - path of the database\n * @param now - instant source, defaults to the system clock\n * @returns what was restored and who queued it, `null` when nothing was queued\n */\nexport function applyPendingRestore(\n\tdbFile: string,\n\tnow: () => number = () => Math.floor(Date.now() / 1000),\n): AppliedRestore | null {\n\tconst pending = readPendingRestore(dbFile);\n\tif (!pending) {\n\t\treturn null;\n\t}\n\n\tconst file = pendingRestorePath(dbFile);\n\tconst restored = verifyBackupFile(file);\n\tconst previous = swapDatabaseFile(dbFile, file, now);\n\t// the queued files have done their job once the database carries their content\n\tfs.rmSync(file, { force: true });\n\tfs.rmSync(pendingRestoreInfoPath(dbFile), { force: true });\n\treturn { restored, previous, actorId: pending.actorId };\n}\n\n/** Default retention in days. */\nconst DEFAULT_RETENTION_DAYS = 30;\n\n/**\n * Formats an instant the way it appears in a file name.\n *\n * @param tsUtc - UTC epoch seconds\n * @returns `YYYY-MM-DDTHH-MM-SS` in UTC\n */\nfunction stamp(tsUtc: number): string {\n\treturn new Date(tsUtc * 1000).toISOString().slice(0, 19).replace(/:/g, \"-\");\n}\n\n/**\n * Reads the instant out of a file name.\n *\n * @param name - file name\n * @param prefix - configured prefix\n * @returns UTC epoch seconds or `null` when the name does not belong to a backup\n */\nfunction createdAtOf(name: string, prefix: string): number | null {\n\tif (!name.startsWith(prefix) || !name.endsWith(\".sqlite\")) {\n\t\treturn null;\n\t}\n\t// `2026-09-14T19-30-00` -> `2026-09-14T19:30:00Z`; only the time part uses dashes\n\tconst raw = name.slice(prefix.length, -\".sqlite\".length);\n\tconst iso = `${raw.slice(0, 11)}${raw.slice(11).replace(/-/g, \":\")}Z`;\n\tconst parsed = Date.parse(iso);\n\treturn Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);\n}\n\n/**\n * Creates the backup service.\n *\n * @param deps - database, directory and retention\n * @returns the service\n */\nexport function createBackupService(deps: BackupDeps): BackupService {\n\tconst prefix = deps.prefix ?? DEFAULT_PREFIX;\n\tconst retentionDays = deps.retentionDays ?? DEFAULT_RETENTION_DAYS;\n\tconst now = deps.now ?? (() => Math.floor(Date.now() / 1000));\n\n\t/**\n\t * Lists the backup files of the directory.\n\t *\n\t * @returns files, newest first\n\t */\n\tfunction list(): BackupFile[] {\n\t\tlet names: string[];\n\t\ttry {\n\t\t\tnames = fs.readdirSync(deps.dir);\n\t\t} catch {\n\t\t\t// the directory does not exist yet: an instance without a backup has none\n\t\t\treturn [];\n\t\t}\n\n\t\tconst files: BackupFile[] = [];\n\t\tfor (const name of names) {\n\t\t\tconst createdAt = createdAtOf(name, prefix);\n\t\t\tif (createdAt === null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst file = path.join(deps.dir, name);\n\t\t\ttry {\n\t\t\t\tfiles.push({ name, file, sizeBytes: fs.statSync(file).size, createdAt });\n\t\t\t} catch {\n\t\t\t\t// a file that disappeared between listing and reading is not listed\n\t\t\t}\n\t\t}\n\t\treturn files.sort((a, b) => b.createdAt - a.createdAt || b.name.localeCompare(a.name));\n\t}\n\n\t/**\n\t * Applies the retention.\n\t *\n\t * @returns names of the removed files\n\t */\n\tfunction rotate(): string[] {\n\t\tconst files = list();\n\t\tconst removed: string[] = [];\n\t\t// the newest backup always stays, even when it is older than the retention\n\t\tfor (const file of files.slice(1)) {\n\t\t\tconst ageDays = (now() - file.createdAt) / 86_400;\n\t\t\tif (ageDays <= retentionDays) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tfs.rmSync(file.file);\n\t\t\t\tremoved.push(file.name);\n\t\t\t} catch {\n\t\t\t\t// a file that cannot be removed (locked, permissions) is kept\n\t\t\t}\n\t\t}\n\t\treturn removed;\n\t}\n\n\t/**\n\t * Checks a backup file.\n\t *\n\t * @param file - path of the file\n\t * @returns information about the checked file\n\t */\n\tfunction verify(file: string): BackupInfo {\n\t\t// the check lives next to the service: the adapter uses it while it starts, when no database is open\n\t\treturn verifyBackupFile(file, prefix);\n\t}\n\n\t/**\n\t * Takes a backup.\n\t *\n\t * @param input - who triggered it and why\n\t * @param input.actorId - user id of the actor, `null` for the system\n\t * @param input.reason - short reason stored in the audit trail\n\t * @returns the created file and the files the retention removed\n\t */\n\tfunction create(input: { actorId?: number | null; reason?: string } = {}): CreateBackupResult {\n\t\tfs.mkdirSync(deps.dir, { recursive: true });\n\t\tconst timestamp = now();\n\t\tconst name = `${prefix}${stamp(timestamp)}.sqlite`;\n\t\tconst target = path.join(deps.dir, name);\n\t\t// a second backup within the same second replaces the first one instead of failing\n\t\tfs.rmSync(target, { force: true });\n\t\tconst partial = `${target}.part`;\n\n\t\ttry {\n\t\t\t// `VACUUM INTO` writes a consistent copy of the open database, so the adapter keeps working\n\t\t\tdeps.db.prepare(\"VACUUM INTO ?\").run(partial);\n\t\t\tfs.renameSync(partial, target);\n\t\t} catch (error) {\n\t\t\tfs.rmSync(partial, { force: true });\n\t\t\tthrow error;\n\t\t}\n\n\t\tconst backup = verify(target);\n\t\twriteAuditLog(deps.db, {\n\t\t\tactorId: input.actorId ?? null,\n\t\t\taction: \"backup.create\",\n\t\t\tentity: \"backup\",\n\t\t\tentityId: name,\n\t\t\tdetail: {\n\t\t\t\tsizeBytes: backup.sizeBytes,\n\t\t\t\tschemaVersion: backup.schemaVersion,\n\t\t\t\tusers: backup.users,\n\t\t\t\tentries: backup.entries,\n\t\t\t\t...(input.reason ? { reason: input.reason } : {}),\n\t\t\t},\n\t\t\tatUtc: timestamp,\n\t\t});\n\n\t\tconst removed = rotate();\n\t\tif (removed.length > 0) {\n\t\t\twriteAuditLog(deps.db, {\n\t\t\t\tactorId: input.actorId ?? null,\n\t\t\t\taction: \"backup.rotate\",\n\t\t\t\tentity: \"backup\",\n\t\t\t\tdetail: { removed },\n\t\t\t\tatUtc: timestamp,\n\t\t\t});\n\t\t}\n\n\t\treturn { backup, removed };\n\t}\n\n\t/**\n\t * Path of the database, refused when it lives only in memory.\n\t *\n\t * @returns path of the database file\n\t */\n\tfunction databaseFile(): string {\n\t\tconst target = deps.db.name;\n\t\tif (!target || target === \":memory:\") {\n\t\t\tthrow new ValidationError(\"an in-memory database cannot be restored\");\n\t\t}\n\t\treturn target;\n\t}\n\n\t/**\n\t * Replaces the database file with a backup.\n\t *\n\t * The caller closes the database first and reopens it afterwards; a backup that does not pass `verify` never\n\t * touches the database file, and the previous file is kept next to it.\n\t *\n\t * @param file - path of the backup\n\t * @returns the restored file and the path the previous database was moved to\n\t */\n\tfunction restore(file: string): RestoreResult {\n\t\tconst restored = verify(file);\n\t\tif (deps.db.open) {\n\t\t\tthrow new ValidationError(\"the database must be closed before it can be restored\");\n\t\t}\n\t\treturn { restored, previous: swapDatabaseFile(databaseFile(), file, now) };\n\t}\n\n\t/**\n\t * Queues a file for the next start.\n\t *\n\t * The file is checked before it is queued: a file that is not a readable database of this schema is refused\n\t * and removed, so a restart can never pick up garbage. The running database stays untouched \u2014 the swap happens\n\t * while the adapter starts, when no connection holds the file open.\n\t *\n\t * @param source - path of the file to queue\n\t * @param input - name for the overview and who queued it\n\t * @param input.name - name for the overview, defaults to the name of the stored file\n\t * @param input.actorId - user id of the actor, `null` for the system\n\t * @param input.reason - short reason stored in the audit trail\n\t * @returns the queued restore\n\t */\n\tfunction queue(\n\t\tsource: string | Buffer,\n\t\tinput: { name?: string; actorId?: number | null; reason?: string },\n\t): PendingRestoreInfo {\n\t\tconst dbFile = databaseFile();\n\t\tconst pendingFile = pendingRestorePath(dbFile);\n\t\tfs.mkdirSync(path.dirname(pendingFile), { recursive: true });\n\n\t\t// The new file is checked before it replaces the queued one: a failed upload must not throw away a restore\n\t\t// that is already waiting, and a file that is not a backup must never be picked up at the next start.\n\t\tconst staged = `${pendingFile}.part`;\n\t\tfs.rmSync(staged, { force: true });\n\t\tif (typeof source === \"string\") {\n\t\t\tfs.copyFileSync(source, staged);\n\t\t} else {\n\t\t\tfs.writeFileSync(staged, source);\n\t\t}\n\n\t\tlet info: BackupInfo;\n\t\ttry {\n\t\t\tinfo = verify(staged);\n\t\t} catch (error) {\n\t\t\t// the staged file is removed, the queued one stays untouched\n\t\t\tfs.rmSync(staged, { force: true });\n\t\t\tthrow error;\n\t\t}\n\t\tfs.renameSync(staged, pendingFile);\n\n\t\tconst queuedAt = now();\n\t\t// The name only labels the queued restore (it is shown and logged), it never becomes a path \u2014 but a name\n\t\t// full of slashes or line breaks has no business in a log line or in a header either.\n\t\tconst label = (input.name ?? \"\").trim().replace(/[^A-Za-z0-9._-]/g, \"_\");\n\t\tconst pending: PendingRestoreInfo = {\n\t\t\tname: label || info.name,\n\t\t\tactorId: input.actorId ?? null,\n\t\t\tqueuedAt,\n\t\t\tsizeBytes: info.sizeBytes,\n\t\t\tusers: info.users,\n\t\t\tentries: info.entries,\n\t\t};\n\t\tfs.writeFileSync(pendingRestoreInfoPath(dbFile), `${JSON.stringify(pending, null, \"\\t\")}\\n`, \"utf8\");\n\t\twriteAuditLog(deps.db, {\n\t\t\tactorId: pending.actorId,\n\t\t\taction: \"backup.queue_restore\",\n\t\t\tentity: \"backup\",\n\t\t\tentityId: pending.name,\n\t\t\tdetail: {\n\t\t\t\tsizeBytes: pending.sizeBytes,\n\t\t\t\tusers: pending.users,\n\t\t\t\tentries: pending.entries,\n\t\t\t\t...(input.reason ? { reason: input.reason } : {}),\n\t\t\t},\n\t\t\tatUtc: queuedAt,\n\t\t});\n\t\treturn pending;\n\t}\n\n\t/**\n\t * Queues an uploaded backup for the next start.\n\t *\n\t * @param upload - bytes of the uploaded file\n\t * @param input - who uploaded it and what it should be called\n\t * @param input.name - name for the download, defaults to the name of the stored file\n\t * @param input.actorId - user id of the actor, `null` for the system\n\t * @param input.reason - short reason stored in the audit trail\n\t * @returns the queued restore\n\t */\n\tfunction queueRestore(\n\t\tupload: Buffer,\n\t\tinput: { name?: string; actorId?: number | null; reason?: string } = {},\n\t): PendingRestoreInfo {\n\t\tif (upload.length === 0) {\n\t\t\tthrow new ValidationError(\"the uploaded file is empty\");\n\t\t}\n\t\t// the bytes go straight to the staging path: a queued restore is not touched by a refused upload\n\t\treturn queue(upload, input);\n\t}\n\n\t/**\n\t * Queues one of the known backups for the next start.\n\t *\n\t * This is the way back for the everyday case: the administration picks a file from the list, the adapter\n\t * applies it while it starts. Only files of the list are accepted, so a name from outside cannot reach the\n\t * file system.\n\t *\n\t * @param name - file name as `list()` reports it\n\t * @param input - who queued it and why\n\t * @param input.actorId - user id of the actor, `null` for the system\n\t * @param input.reason - short reason stored in the audit trail\n\t * @returns the queued restore\n\t */\n\tfunction queueExistingBackup(\n\t\tname: string,\n\t\tinput: { actorId?: number | null; reason?: string } = {},\n\t): PendingRestoreInfo {\n\t\tconst known = list().find(entry => entry.name === name);\n\t\tif (!known) {\n\t\t\tthrow new ValidationError(`backup ${name} does not exist`);\n\t\t}\n\t\treturn queue(known.file, { ...input, name });\n\t}\n\n\t/**\n\t * Deletes one of the known backups.\n\t *\n\t * Only files of the list are deleted, so the requested name never reaches the file system on its own. The\n\t * file that waits for the next start is none of them: that is not a backup but the pending restore.\n\t *\n\t * @param name - file name as `list()` reports it\n\t * @param input - who removed it and why\n\t * @param input.actorId - user id of the actor, `null` for the system\n\t * @param input.reason - short reason stored in the audit trail\n\t * @returns the removed file\n\t */\n\tfunction remove(name: string, input: { actorId?: number | null; reason?: string } = {}): BackupFile {\n\t\tconst known = list().find(entry => entry.name === name);\n\t\tif (!known) {\n\t\t\tthrow new ValidationError(`backup ${name} does not exist`);\n\t\t}\n\t\tfs.rmSync(known.file, { force: true });\n\t\twriteAuditLog(deps.db, {\n\t\t\tactorId: input.actorId ?? null,\n\t\t\taction: \"backup.remove\",\n\t\t\tentity: \"backup\",\n\t\t\tentityId: known.name,\n\t\t\tdetail: {\n\t\t\t\tsizeBytes: known.sizeBytes,\n\t\t\t\t...(input.reason ? { reason: input.reason } : {}),\n\t\t\t},\n\t\t\tatUtc: now(),\n\t\t});\n\t\treturn known;\n\t}\n\n\t/**\n\t * The restore that waits for the next start.\n\t *\n\t * A database that lives only in memory cannot be restored at all, so it reports nothing instead of failing:\n\t * the list of backups stays readable.\n\t *\n\t * @returns the queued restore or `null`\n\t */\n\tfunction pending(): PendingRestoreInfo | null {\n\t\tconst target = deps.db.name;\n\t\tif (!target || target === \":memory:\") {\n\t\t\treturn null;\n\t\t}\n\t\treturn readPendingRestore(target);\n\t}\n\n\treturn { create, list, rotate, verify, restore, queueRestore, queueExistingBackup, remove, pending };\n}\n\n/**\n * Records a completed restore in the audit trail.\n *\n * The database is closed while a backup is restored, so the entry can only be written afterwards \u2014 the caller\n * writes it right after reopening the restored file.\n *\n * @param db - reopened database handle\n * @param info - the restored backup\n * @param actorId - who restored it, `null` for the system\n * @param atUtc - instant of the restore, defaults to now\n * @returns id of the created audit row\n */\nexport function recordRestore(db: Db, info: BackupInfo, actorId: number | null, atUtc?: number): number {\n\treturn writeAuditLog(db, {\n\t\tactorId,\n\t\taction: \"backup.restore\",\n\t\tentity: \"backup\",\n\t\tentityId: info.name,\n\t\tdetail: {\n\t\t\tsizeBytes: info.sizeBytes,\n\t\t\tschemaVersion: info.schemaVersion,\n\t\t\tusers: info.users,\n\t\t\tentries: info.entries,\n\t\t},\n\t\tatUtc,\n\t});\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["Database", "_a", "_b", "pending"]
7
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var closing_exports = {};
20
+ __export(closing_exports, {
21
+ createClosingService: () => createClosingService
22
+ });
23
+ module.exports = __toCommonJS(closing_exports);
24
+ var import_errors = require("../errors");
25
+ var import_audit = require("../db/repositories/audit");
26
+ function createClosingService(deps) {
27
+ const { db, aggregation, payouts } = deps;
28
+ return {
29
+ closeMonth(input) {
30
+ var _a, _b, _c, _d, _e, _f, _g;
31
+ if (!Number.isInteger(input.month) || input.month < 1 || input.month > 12) {
32
+ throw new import_errors.ValidationError(`month must be between 1 and 12 (got ${input.month})`);
33
+ }
34
+ const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
35
+ const payoutMinutes = (_b = input.payoutMinutes) != null ? _b : 0;
36
+ const payout = payoutMinutes === 0 ? null : payouts.create({
37
+ userId: input.userId,
38
+ year: input.year,
39
+ month: input.month,
40
+ minutes: payoutMinutes,
41
+ amount: (_c = input.payoutAmount) != null ? _c : null,
42
+ note: (_d = input.note) != null ? _d : `Monatsabschluss ${input.year}-${String(input.month).padStart(2, "0")}`,
43
+ actorId: input.actorId,
44
+ actorIp: (_e = input.actorIp) != null ? _e : null,
45
+ now
46
+ });
47
+ const month = aggregation.recalculateMonth(input.userId, input.year, input.month, {
48
+ now,
49
+ upToDate: input.upToDate
50
+ });
51
+ const year = aggregation.recalculateYear(input.userId, input.year, { now, upToDate: input.upToDate });
52
+ (0, import_audit.writeAuditLog)(db, {
53
+ atUtc: now,
54
+ actorId: input.actorId,
55
+ action: "month.close",
56
+ entity: "month_aggregate",
57
+ entityId: `${input.userId}:${input.year}-${String(input.month).padStart(2, "0")}`,
58
+ detail: {
59
+ year: input.year,
60
+ month: input.month,
61
+ workedMin: month.workedMin,
62
+ targetMin: month.targetMin,
63
+ balanceMin: month.balanceMin,
64
+ overtimeMin: month.overtimeMin,
65
+ vacationUsed: month.vacationUsed,
66
+ payoutMinutes: payout ? payout.minutes : 0,
67
+ note: (_f = input.note) != null ? _f : null
68
+ },
69
+ ip: (_g = input.actorIp) != null ? _g : null
70
+ });
71
+ return { month, year, payout, closedAt: now };
72
+ },
73
+ settleYear(input) {
74
+ var _a, _b;
75
+ const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
76
+ const year = aggregation.recalculateYear(input.userId, input.year, {
77
+ settled: true,
78
+ now,
79
+ upToDate: input.upToDate
80
+ });
81
+ (0, import_audit.writeAuditLog)(db, {
82
+ atUtc: now,
83
+ actorId: input.actorId,
84
+ action: "year.settle",
85
+ entity: "year_aggregate",
86
+ entityId: `${input.userId}:${input.year}`,
87
+ detail: {
88
+ year: input.year,
89
+ workedMin: year.workedMin,
90
+ targetMin: year.targetMin,
91
+ overtimeMin: year.overtimeMin,
92
+ vacationUsed: year.vacationUsed,
93
+ vacationLeft: year.vacationLeft,
94
+ settled: year.settled
95
+ },
96
+ ip: (_b = input.actorIp) != null ? _b : null
97
+ });
98
+ return { year, closedAt: now };
99
+ }
100
+ };
101
+ }
102
+ // Annotate the CommonJS export names for ESM import in node:
103
+ 0 && (module.exports = {
104
+ createClosingService
105
+ });
106
+ //# sourceMappingURL=closing.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/services/closing.ts"],
4
+ "sourcesContent": ["/**\n * Closing service: the administrative workflow around the aggregates.\n *\n * Closing a month recalculates it (and its year) and optionally records an overtime payout \u2014 the payout is\n * written first, so the overtime of the closing already accounts for it. Settling a year recalculates it\n * with `settled = true`, which finishes the `yearly` overtime model and freezes the result.\n *\n * Both operations are audited (`month.close`, `year.settle`), so a closing can always be traced back to the\n * person who did it.\n */\n\nimport type { Db } from \"../db/database\";\nimport type { PayoutRecord, PayoutsRepository } from \"../db/repositories/payouts\";\nimport type { AggregationService, MonthAggregateRecord, YearAggregateRecord } from \"./aggregation\";\nimport { ValidationError } from \"../errors\";\nimport { writeAuditLog } from \"../db/repositories/audit\";\n\n/** Data sources of the closing service. */\nexport interface ClosingDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** Aggregation service used for the recalculation */\n\taggregation: AggregationService;\n\t/** Payout storage */\n\tpayouts: PayoutsRepository;\n}\n\n/** Input for closing a month. */\nexport interface CloseMonthInput {\n\t/** Employee whose month is closed */\n\tuserId: number;\n\t/** Four digit year */\n\tyear: number;\n\t/** Month, 1 to 12 */\n\tmonth: number;\n\t/** Overtime to pay out with this closing, in minutes */\n\tpayoutMinutes?: number;\n\t/** Amount paid with this closing, informational only */\n\tpayoutAmount?: number | null;\n\t/** Note stored with the payout */\n\tnote?: string | null;\n\t/** Who closes the month */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Local date up to which days are aggregated (default: today in the user's time zone) */\n\tupToDate?: string;\n\t/** Instant of the closing, defaults to now */\n\tnow?: number;\n}\n\n/** Result of closing a month. */\nexport interface CloseMonthResult {\n\t/** Monthly aggregate after the closing */\n\tmonth: MonthAggregateRecord;\n\t/** Yearly aggregate after the closing */\n\tyear: YearAggregateRecord;\n\t/** Payout recorded with the closing, `null` when no overtime was paid out */\n\tpayout: PayoutRecord | null;\n\t/** Instant of the closing */\n\tclosedAt: number;\n}\n\n/** Input for settling a year. */\nexport interface SettleYearInput {\n\t/** Employee whose year is settled */\n\tuserId: number;\n\t/** Four digit year */\n\tyear: number;\n\t/** Who settles the year */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Local date up to which days are aggregated (default: today in the user's time zone) */\n\tupToDate?: string;\n\t/** Instant of the closing, defaults to now */\n\tnow?: number;\n}\n\n/** Result of settling a year. */\nexport interface SettleYearResult {\n\t/** Yearly aggregate after the closing */\n\tyear: YearAggregateRecord;\n\t/** Instant of the closing */\n\tclosedAt: number;\n}\n\n/** Closing operations. */\nexport interface ClosingService {\n\t/** Recalculates a month and its year, optionally paying out overtime */\n\tcloseMonth(input: CloseMonthInput): CloseMonthResult;\n\t/** Recalculates a year and marks it as settled */\n\tsettleYear(input: SettleYearInput): SettleYearResult;\n}\n\n/**\n * Creates the closing service.\n *\n * @param deps - data sources\n * @returns service instance\n */\nexport function createClosingService(deps: ClosingDeps): ClosingService {\n\tconst { db, aggregation, payouts } = deps;\n\n\treturn {\n\t\tcloseMonth(input: CloseMonthInput): CloseMonthResult {\n\t\t\tif (!Number.isInteger(input.month) || input.month < 1 || input.month > 12) {\n\t\t\t\tthrow new ValidationError(`month must be between 1 and 12 (got ${input.month})`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst payoutMinutes = input.payoutMinutes ?? 0;\n\n\t\t\t// the payout is recorded first so the recalculation already includes it\n\t\t\tconst payout =\n\t\t\t\tpayoutMinutes === 0\n\t\t\t\t\t? null\n\t\t\t\t\t: payouts.create({\n\t\t\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\t\t\tyear: input.year,\n\t\t\t\t\t\t\tmonth: input.month,\n\t\t\t\t\t\t\tminutes: payoutMinutes,\n\t\t\t\t\t\t\tamount: input.payoutAmount ?? null,\n\t\t\t\t\t\t\tnote: input.note ?? `Monatsabschluss ${input.year}-${String(input.month).padStart(2, \"0\")}`,\n\t\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\t\tnow,\n\t\t\t\t\t\t});\n\n\t\t\tconst month = aggregation.recalculateMonth(input.userId, input.year, input.month, {\n\t\t\t\tnow,\n\t\t\t\tupToDate: input.upToDate,\n\t\t\t});\n\t\t\tconst year = aggregation.recalculateYear(input.userId, input.year, { now, upToDate: input.upToDate });\n\n\t\t\twriteAuditLog(db, {\n\t\t\t\tatUtc: now,\n\t\t\t\tactorId: input.actorId,\n\t\t\t\taction: \"month.close\",\n\t\t\t\tentity: \"month_aggregate\",\n\t\t\t\tentityId: `${input.userId}:${input.year}-${String(input.month).padStart(2, \"0\")}`,\n\t\t\t\tdetail: {\n\t\t\t\t\tyear: input.year,\n\t\t\t\t\tmonth: input.month,\n\t\t\t\t\tworkedMin: month.workedMin,\n\t\t\t\t\ttargetMin: month.targetMin,\n\t\t\t\t\tbalanceMin: month.balanceMin,\n\t\t\t\t\tovertimeMin: month.overtimeMin,\n\t\t\t\t\tvacationUsed: month.vacationUsed,\n\t\t\t\t\tpayoutMinutes: payout ? payout.minutes : 0,\n\t\t\t\t\tnote: input.note ?? null,\n\t\t\t\t},\n\t\t\t\tip: input.actorIp ?? null,\n\t\t\t});\n\n\t\t\treturn { month, year, payout, closedAt: now };\n\t\t},\n\n\t\tsettleYear(input: SettleYearInput): SettleYearResult {\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst year = aggregation.recalculateYear(input.userId, input.year, {\n\t\t\t\tsettled: true,\n\t\t\t\tnow,\n\t\t\t\tupToDate: input.upToDate,\n\t\t\t});\n\n\t\t\twriteAuditLog(db, {\n\t\t\t\tatUtc: now,\n\t\t\t\tactorId: input.actorId,\n\t\t\t\taction: \"year.settle\",\n\t\t\t\tentity: \"year_aggregate\",\n\t\t\t\tentityId: `${input.userId}:${input.year}`,\n\t\t\t\tdetail: {\n\t\t\t\t\tyear: input.year,\n\t\t\t\t\tworkedMin: year.workedMin,\n\t\t\t\t\ttargetMin: year.targetMin,\n\t\t\t\t\tovertimeMin: year.overtimeMin,\n\t\t\t\t\tvacationUsed: year.vacationUsed,\n\t\t\t\t\tvacationLeft: year.vacationLeft,\n\t\t\t\t\tsettled: year.settled,\n\t\t\t\t},\n\t\t\t\tip: input.actorIp ?? null,\n\t\t\t});\n\n\t\t\treturn { year, closedAt: now };\n\t\t},\n\t};\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAcA,oBAAgC;AAChC,mBAA8B;AAsFvB,SAAS,qBAAqB,MAAmC;AACvE,QAAM,EAAE,IAAI,aAAa,QAAQ,IAAI;AAErC,SAAO;AAAA,IACN,WAAW,OAA0C;AAzGvD;AA0GG,UAAI,CAAC,OAAO,UAAU,MAAM,KAAK,KAAK,MAAM,QAAQ,KAAK,MAAM,QAAQ,IAAI;AAC1E,cAAM,IAAI,8BAAgB,uCAAuC,MAAM,KAAK,GAAG;AAAA,MAChF;AACA,YAAM,OAAM,WAAM,QAAN,YAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACrD,YAAM,iBAAgB,WAAM,kBAAN,YAAuB;AAG7C,YAAM,SACL,kBAAkB,IACf,OACA,QAAQ,OAAO;AAAA,QACf,QAAQ,MAAM;AAAA,QACd,MAAM,MAAM;AAAA,QACZ,OAAO,MAAM;AAAA,QACb,SAAS;AAAA,QACT,SAAQ,WAAM,iBAAN,YAAsB;AAAA,QAC9B,OAAM,WAAM,SAAN,YAAc,mBAAmB,MAAM,IAAI,IAAI,OAAO,MAAM,KAAK,EAAE,SAAS,GAAG,GAAG,CAAC;AAAA,QACzF,SAAS,MAAM;AAAA,QACf,UAAS,WAAM,YAAN,YAAiB;AAAA,QAC1B;AAAA,MACD,CAAC;AAEJ,YAAM,QAAQ,YAAY,iBAAiB,MAAM,QAAQ,MAAM,MAAM,MAAM,OAAO;AAAA,QACjF;AAAA,QACA,UAAU,MAAM;AAAA,MACjB,CAAC;AACD,YAAM,OAAO,YAAY,gBAAgB,MAAM,QAAQ,MAAM,MAAM,EAAE,KAAK,UAAU,MAAM,SAAS,CAAC;AAEpG,sCAAc,IAAI;AAAA,QACjB,OAAO;AAAA,QACP,SAAS,MAAM;AAAA,QACf,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,UAAU,GAAG,MAAM,MAAM,IAAI,MAAM,IAAI,IAAI,OAAO,MAAM,KAAK,EAAE,SAAS,GAAG,GAAG,CAAC;AAAA,QAC/E,QAAQ;AAAA,UACP,MAAM,MAAM;AAAA,UACZ,OAAO,MAAM;AAAA,UACb,WAAW,MAAM;AAAA,UACjB,WAAW,MAAM;AAAA,UACjB,YAAY,MAAM;AAAA,UAClB,aAAa,MAAM;AAAA,UACnB,cAAc,MAAM;AAAA,UACpB,eAAe,SAAS,OAAO,UAAU;AAAA,UACzC,OAAM,WAAM,SAAN,YAAc;AAAA,QACrB;AAAA,QACA,KAAI,WAAM,YAAN,YAAiB;AAAA,MACtB,CAAC;AAED,aAAO,EAAE,OAAO,MAAM,QAAQ,UAAU,IAAI;AAAA,IAC7C;AAAA,IAEA,WAAW,OAA0C;AA7JvD;AA8JG,YAAM,OAAM,WAAM,QAAN,YAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACrD,YAAM,OAAO,YAAY,gBAAgB,MAAM,QAAQ,MAAM,MAAM;AAAA,QAClE,SAAS;AAAA,QACT;AAAA,QACA,UAAU,MAAM;AAAA,MACjB,CAAC;AAED,sCAAc,IAAI;AAAA,QACjB,OAAO;AAAA,QACP,SAAS,MAAM;AAAA,QACf,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,UAAU,GAAG,MAAM,MAAM,IAAI,MAAM,IAAI;AAAA,QACvC,QAAQ;AAAA,UACP,MAAM,MAAM;AAAA,UACZ,WAAW,KAAK;AAAA,UAChB,WAAW,KAAK;AAAA,UAChB,aAAa,KAAK;AAAA,UAClB,cAAc,KAAK;AAAA,UACnB,cAAc,KAAK;AAAA,UACnB,SAAS,KAAK;AAAA,QACf;AAAA,QACA,KAAI,WAAM,YAAN,YAAiB;AAAA,MACtB,CAAC;AAED,aAAO,EAAE,MAAM,UAAU,IAAI;AAAA,IAC9B;AAAA,EACD;AACD;",
6
+ "names": []
7
+ }