iobroker.time-tracker 0.2.7 → 0.3.1
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/README.md +23 -15
- package/build/lib/db/migrations.js +9 -0
- package/build/lib/db/migrations.js.map +2 -2
- package/build/lib/db/repositories/users.js +13 -7
- package/build/lib/db/repositories/users.js.map +2 -2
- package/build/lib/services/aggregation.js +2 -1
- package/build/lib/services/aggregation.js.map +2 -2
- package/build/lib/web/api.js +20 -5
- package/build/lib/web/api.js.map +2 -2
- package/io-package.json +27 -27
- package/package.json +1 -1
- package/src-pwa/src/i18n/de.json +4 -1
- package/src-pwa/src/i18n/en.json +4 -1
- package/src-pwa/src/i18n/es.json +4 -1
- package/src-pwa/src/i18n/fr.json +4 -1
- package/src-pwa/src/i18n/it.json +4 -1
- package/src-pwa/src/i18n/nl.json +4 -1
- package/src-pwa/src/i18n/pl.json +4 -1
- package/src-pwa/src/i18n/pt.json +4 -1
- package/src-pwa/src/i18n/ru.json +4 -1
- package/src-pwa/src/i18n/uk.json +4 -1
- package/src-pwa/src/i18n/zh-cn.json +4 -1
- package/www/assets/{index-QI3wrEvW.js → index-BOdNralo.js} +3 -3
- package/www/assets/index-BOdNralo.js.map +1 -0
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
- package/www/assets/index-QI3wrEvW.js.map +0 -1
package/README.md
CHANGED
|
@@ -131,6 +131,11 @@ Everything is served on the port of the instance settings:
|
|
|
131
131
|
| `/terminal?token=…` | the kiosk screen (badge and PIN) |
|
|
132
132
|
| `/presence?token=…` | the board “who is at work right now” |
|
|
133
133
|
|
|
134
|
+
The board shows every employee with the state of the day. The **worked time of today** appears next to it
|
|
135
|
+
(`1:23`, `0:00` before the first punch of the day) once the **work profile** of that employee allows it
|
|
136
|
+
(*Arbeitszeit auf der Anwesenheitskarte*, off by default): the board is visible before the PIN is entered, so the
|
|
137
|
+
administration decides this per employee — the API only sends the minutes for employees who agreed.
|
|
138
|
+
|
|
134
139
|
### Terminal (kiosk)
|
|
135
140
|
|
|
136
141
|
Switch **Enable kiosk terminal** on, then create the device in **Administration → Terminals** and copy the device
|
|
@@ -348,6 +353,24 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
348
353
|
|
|
349
354
|
### **WORK IN PROGRESS**
|
|
350
355
|
|
|
356
|
+
### 0.3.1 (2026-09-22)
|
|
357
|
+
|
|
358
|
+
- (Alex) fix: the presence board shows the **real running** time of the day now. Two things kept it frozen:
|
|
359
|
+
`/terminal/users` answered with the stored day aggregate, which only changes on a punch (it counts finished
|
|
360
|
+
pairs), and the tile “ticker” measured the seconds since the last server answer — a refresh every 60 s never let
|
|
361
|
+
it grow beyond 0. The API adds the minutes of an open punch to its answer now, the tile shows that value directly,
|
|
362
|
+
and the board refreshes every 20 s (was 60 s). The API test moves the clock ten minutes and expects the open punch
|
|
363
|
+
in the answer
|
|
364
|
+
|
|
365
|
+
### 0.3.0 (2026-09-22)
|
|
366
|
+
|
|
367
|
+
- (Alex) new: the presence board can show the working time of today on the employee tiles — `1:23` next to
|
|
368
|
+
`Present`/`Away` (`0:00` before the first punch), and the tile keeps counting while the employee is present. It is
|
|
369
|
+
switched on **per employee** in the work profile (*Arbeitszeit auf der Anwesenheitskarte*, off by default), because
|
|
370
|
+
the board is visible before the PIN is entered — the decision is made on the server, the API only sends the minutes
|
|
371
|
+
for employees who agreed (`/terminal/users` and `/terminal/punch`). The new column
|
|
372
|
+
`work_profiles.show_worked_time` arrives with migration 20
|
|
373
|
+
|
|
351
374
|
### 0.2.7 (2026-09-22)
|
|
352
375
|
|
|
353
376
|
- (Alex) fix: **every** punch path reaches `events.*` and the web app now. Besides the presence state (0.2.6) the
|
|
@@ -372,21 +395,6 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
372
395
|
automatically. Building from a checkout is only the developer path, and the chapter explains why a Git install
|
|
373
396
|
reports `cannot find start file!` (`build/` and `www/` are not part of the repository)
|
|
374
397
|
|
|
375
|
-
### 0.2.4 (2026-09-21)
|
|
376
|
-
|
|
377
|
-
- (Alex) `commands.punchUserId` does what it promises now: writing an employee id selects the employee the punch
|
|
378
|
-
buttons apply to (`0` = the only employee again), and the adapter mirrors the current choice back into the state.
|
|
379
|
-
For the user the README has a new *Commands (states)* section and `docs/erste-schritte.md` explains all six command
|
|
380
|
-
states — both with copy-ready examples and the two traps (buttons act on `true` only, a wrong period answers in the
|
|
381
|
-
log)
|
|
382
|
-
|
|
383
|
-
### 0.2.3 (2026-09-20)
|
|
384
|
-
|
|
385
|
-
- (Alex) fix: a corrected object definition really reaches existing installations now — `ensureObject` merges the
|
|
386
|
-
fields the adapter owns (`name`, `type`, `role`, `read`, `write`, `unit`) instead of only the name. An installation
|
|
387
|
-
created before 0.2.2 kept its old `role: "value"` on `commands.punchUserId`, so the object structure check still
|
|
388
|
-
reported `E1011`
|
|
389
|
-
|
|
390
398
|
Older entries are kept in [`CHANGELOG_OLD.md`](CHANGELOG_OLD.md).
|
|
391
399
|
|
|
392
400
|
## Provenance
|
|
@@ -593,6 +593,15 @@ const migrations = [
|
|
|
593
593
|
UPDATE holidays SET key = 'whitMonday' WHERE key IS NULL AND name = 'Whit Monday';
|
|
594
594
|
UPDATE holidays SET key = 'corpusChristi' WHERE key IS NULL AND name = 'Corpus Christi';
|
|
595
595
|
`
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
version: 20,
|
|
599
|
+
name: "work profiles: whether the presence card shows the working time of the day",
|
|
600
|
+
sql: `
|
|
601
|
+
-- The presence screen is visible before the PIN is entered, so showing the worked time of an employee
|
|
602
|
+
-- is a decision of the administration, per employee. Off by default.
|
|
603
|
+
ALTER TABLE work_profiles ADD COLUMN show_worked_time INTEGER NOT NULL DEFAULT 0;
|
|
604
|
+
`
|
|
596
605
|
}
|
|
597
606
|
];
|
|
598
607
|
// Annotate the CommonJS export names for ESM import in node:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/db/migrations.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Versioned database migrations.\n *\n * The schema follows the internal specification (section 2). Rules:\n * - Times are stored as UTC epoch seconds (INTEGER); durations as whole minutes.\n * - Local date/time fields (`ts_local`, `local_date`) are derived caches of `ts_utc` + user time zone.\n * - Migrations are never *reinterpreted*: a released migration keeps its effect for databases that already\n * ran it, and every schema change comes as a new migration. Definitions that only freshly created\n * databases ever see may be tidied up afterwards, as long as fresh and upgraded installations end up with\n * the same schema (see migration 10, which cleans up behind migrations 1, 2 and 5).\n */\n\nimport type { Db } from \"./database\";\n\n/**\n * A single schema migration: either plain SQL or a JavaScript step for changes that SQL cannot express\n * (e.g. \u201Cdrop this column, but only when it is still there\u201D).\n */\nexport interface Migration {\n\t/** Monotonically increasing version number, applied in ascending order */\n\tversion: number;\n\t/** Short description, stored in `schema_migrations` for traceability */\n\tname: string;\n\t/** SQL statements (may contain several statements separated by semicolons) */\n\tsql?: string;\n\t/** JavaScript steps, run inside the same transaction as `sql` */\n\trun?: (db: Db) => void;\n}\n\n/**\n * Checks whether a table has a column.\n *\n * Migrations that clean up after older versions use this to stay safe for both kinds of database: one that was\n * created by an older version and one that was created by the current one (which never creates the column).\n *\n * @param db - open database handle\n * @param table - table name\n * @param column - column name\n * @returns true when the column exists\n */\nexport function hasColumn(db: Db, table: string, column: string): boolean {\n\tconst rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];\n\treturn rows.some(row => row.name === column);\n}\n\nexport const migrations: Migration[] = [\n\t{\n\t\tversion: 1,\n\t\tname: \"core: users, roles, permissions, work profiles, shift and pause rules, settings, sessions, audit, import runs\",\n\t\tsql: `\n\t\t\tCREATE TABLE users (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlogin TEXT NOT NULL UNIQUE,\n\t\t\t\tpassword_hash TEXT NOT NULL DEFAULT '',\n\t\t\t\tdisplay_name TEXT NOT NULL,\n\t\t\t\temail TEXT,\n\t\t\t\trfid_card TEXT,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tmust_change_pw INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tlocale TEXT NOT NULL DEFAULT 'de-DE',\n\t\t\t\ttimezone TEXT NOT NULL DEFAULT 'Europe/Berlin',\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tlast_login_at INTEGER\n\t\t\t);\n\n\t\t\tCREATE TABLE roles (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tkey TEXT NOT NULL UNIQUE,\n\t\t\t\tname TEXT NOT NULL\n\t\t\t);\n\n\t\t\tCREATE TABLE permissions (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tkey TEXT NOT NULL UNIQUE\n\t\t\t);\n\n\t\t\tCREATE TABLE role_permissions (\n\t\t\t\trole_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,\n\t\t\t\tpermission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,\n\t\t\t\tPRIMARY KEY (role_id, permission_id)\n\t\t\t);\n\n\t\t\tCREATE TABLE user_roles (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\trole_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,\n\t\t\t\tPRIMARY KEY (user_id, role_id)\n\t\t\t);\n\n\t\t\tCREATE TABLE work_profiles (\n\t\t\t\tuser_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tpercent REAL NOT NULL DEFAULT 100,\n\t\t\t\tweekly_hours REAL NOT NULL DEFAULT 42.5,\n\t\t\t\tworkdays TEXT NOT NULL DEFAULT '0;1;1;1;1;1;0',\n\t\t\t\tstart_date INTEGER,\n\t\t\t\tend_date INTEGER,\n\t\t\t\tovertime_carryover INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvorholzeit_per_year INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvacation_carryover REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_per_year REAL NOT NULL DEFAULT 0,\n\t\t\t\tovertime_model TEXT NOT NULL DEFAULT 'monthly'\n\t\t\t\t CHECK (overtime_model IN ('cumulative','yearly','monthly')),\n\t\t\t\tholiday_flags TEXT\n\t\t\t);\n\n\t\t\tCREATE TABLE shift_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tday_of_week INTEGER CHECK (day_of_week IS NULL OR day_of_week BETWEEN 0 AND 6),\n\t\t\t\tfrom_min INTEGER,\n\t\t\t\tto_min INTEGER,\n\t\t\t\tsurcharge REAL NOT NULL DEFAULT 0,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1\n\t\t\t);\n\n\t\t\tCREATE TABLE pause_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tfrom_min INTEGER NOT NULL,\n\t\t\t\tto_min INTEGER,\n\t\t\t\tpause_min INTEGER NOT NULL,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tCHECK (to_min IS NULL OR to_min > from_min)\n\t\t\t);\n\n\t\t\tCREATE TABLE app_settings (\n\t\t\t\tkey TEXT PRIMARY KEY,\n\t\t\t\tvalue TEXT NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\n\t\t\tCREATE TABLE sessions (\n\t\t\t\tid TEXT PRIMARY KEY,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\texpires_at INTEGER NOT NULL,\n\t\t\t\tuser_agent TEXT,\n\t\t\t\tip TEXT,\n\t\t\t\trevoked INTEGER NOT NULL DEFAULT 0\n\t\t\t);\n\n\t\t\tCREATE TABLE audit_log (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tat_utc INTEGER NOT NULL,\n\t\t\t\tactor_id INTEGER,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tentity TEXT,\n\t\t\t\tentity_id TEXT,\n\t\t\t\tdetail TEXT,\n\t\t\t\tip TEXT\n\t\t\t);\n\n\t\t\tCREATE TABLE import_runs (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tsource TEXT NOT NULL,\n\t\t\t\tsource_path TEXT NOT NULL,\n\t\t\t\tstarted_at INTEGER NOT NULL,\n\t\t\t\tfinished_at INTEGER,\n\t\t\t\tmode TEXT NOT NULL DEFAULT 'dry-run'\n\t\t\t\t CHECK (mode IN ('dry-run','commit')),\n\t\t\t\tstatus TEXT NOT NULL DEFAULT 'running'\n\t\t\t\t CHECK (status IN ('running','ok','warnings','mismatch','failed')),\n\t\t\t\ttimezone_assumed TEXT,\n\t\t\t\tstats TEXT,\n\t\t\t\twarnings TEXT,\n\t\t\t\tactor_id INTEGER REFERENCES users(id)\n\t\t\t);\n\t\t`,\n\t},\n\t{\n\t\tversion: 2,\n\t\tname: \"time: entries, audit, day/month/year aggregates, payouts\",\n\t\tsql: `\n\t\t\tCREATE TABLE time_entries (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tts_utc INTEGER NOT NULL,\n\t\t\t\tclient_ts_utc INTEGER,\n\t\t\t\tts_local INTEGER NOT NULL,\n\t\t\t\tlocal_date TEXT NOT NULL,\n\t\t\t\tdirection TEXT NOT NULL DEFAULT 'auto'\n\t\t\t\t CHECK (direction IN ('in','out','auto')),\n\t\t\t\tsource TEXT NOT NULL DEFAULT 'web'\n\t\t\t\t CHECK (source IN ('web','nfc','terminal','api','admin','import')),\n\t\t\t\tidempotency_key TEXT,\n\t\t\t\tsync_state TEXT NOT NULL DEFAULT 'synced'\n\t\t\t\t CHECK (sync_state IN ('synced','pending','conflict')),\n\t\t\t\trevision INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tnote TEXT,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id),\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tupdated_by INTEGER REFERENCES users(id)\n\t\t\t);\n\n\t\t\tCREATE INDEX idx_entries_user_date ON time_entries(user_id, local_date);\n\t\t\tCREATE INDEX idx_entries_user_ts ON time_entries(user_id, ts_utc);\n\t\t\tCREATE UNIQUE INDEX idx_entries_idem\n\t\t\t\tON time_entries(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL;\n\n\t\t\tCREATE TABLE time_entry_audit (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tentry_id INTEGER,\n\t\t\t\tuser_id INTEGER NOT NULL,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tchanges TEXT,\n\t\t\t\told_ts_utc INTEGER,\n\t\t\t\tnew_ts_utc INTEGER,\n\t\t\t\trevision INTEGER,\n\t\t\t\treason TEXT,\n\t\t\t\tactor_id INTEGER NOT NULL,\n\t\t\t\tactor_ip TEXT,\n\t\t\t\tat_utc INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE INDEX idx_entry_audit_entry ON time_entry_audit(entry_id);\n\t\t`,\n\t},\n\t{\n\t\tversion: 3,\n\t\tname: \"aggregates: day, month, year and payouts\",\n\t\tsql: `\n\t\t\tCREATE TABLE day_aggregates (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tlocal_date TEXT NOT NULL,\n\t\t\t\tworked_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tbreak_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\ttarget_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tbalance_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tabsence_code TEXT,\n\t\t\t\tis_holiday INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tfirst_in_utc INTEGER,\n\t\t\t\tlast_out_utc INTEGER,\n\t\t\t\thas_open_entry INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (user_id, local_date)\n\t\t\t);\n\n\t\t\tCREATE TABLE month_aggregates (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tmonth INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),\n\t\t\t\tworked_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\ttarget_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tbalance_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tovertime_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvacation_used REAL NOT NULL DEFAULT 0,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (user_id, year, month)\n\t\t\t);\n\n\t\t\tCREATE TABLE year_aggregates (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tworked_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\ttarget_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tovertime_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tovertime_start INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvacation_days REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_used REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_left REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_planned_days REAL NOT NULL DEFAULT 0,\n\t\t\t\tsettled INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (user_id, year)\n\t\t\t);\n\n\t\t\tCREATE TABLE payouts (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tmonth INTEGER,\n\t\t\t\tminutes INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tamount REAL,\n\t\t\t\tnote TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id)\n\t\t\t);\n\t\t\tCREATE INDEX idx_payouts_user ON payouts(user_id, year, month);\n\t\t`,\n\t},\n\t{\n\t\tversion: 4,\n\t\tname: \"absence: types, absences, holidays\",\n\t\tsql: `\n\t\t\tCREATE TABLE absence_types (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tcode TEXT NOT NULL,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tpaid INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tfactor REAL NOT NULL DEFAULT 100,\n\t\t\t\treduce_vacation INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1\n\t\t\t);\n\n\t\t\tCREATE UNIQUE INDEX idx_absence_types_global\n\t\t\t\tON absence_types(code) WHERE user_id IS NULL;\n\t\t\tCREATE UNIQUE INDEX idx_absence_types_user\n\t\t\t\tON absence_types(user_id, code) WHERE user_id IS NOT NULL;\n\n\t\t\tCREATE TABLE absences (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\ttype_id INTEGER NOT NULL REFERENCES absence_types(id),\n\t\t\t\tdate_from TEXT NOT NULL,\n\t\t\t\tdate_to TEXT NOT NULL,\n\t\t\t\tday_portion REAL NOT NULL DEFAULT 1 CHECK (day_portion > 0 AND day_portion <= 1),\n\t\t\t\thours REAL,\n\t\t\t\tstatus TEXT NOT NULL DEFAULT 'taken'\n\t\t\t\t CHECK (status IN ('taken','planned')),\n\t\t\t\tnote TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id),\n\t\t\t\tCHECK (date_from <= date_to)\n\t\t\t);\n\t\t\tCREATE INDEX idx_absences_user_dates ON absences(user_id, date_from, date_to);\n\n\t\t\tCREATE TABLE holidays (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tregion TEXT NOT NULL DEFAULT 'global',\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tdate TEXT NOT NULL,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tUNIQUE (region, date)\n\t\t\t);\n\t\t\tCREATE INDEX idx_holidays_year ON holidays(year, region);\n\t\t`,\n\t},\n\t{\n\t\tversion: 5,\n\t\tname: \"devices: rfid tags and kiosk terminals\",\n\t\tsql: `\n\t\t\tCREATE TABLE rfid_tags (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuid TEXT,\n\t\t\t\ttoken_hash TEXT NOT NULL,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE SET NULL,\n\t\t\t\tlabel TEXT,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\texpires_at INTEGER,\n\t\t\t\tlast_used_at INTEGER,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE UNIQUE INDEX idx_rfid_uid ON rfid_tags(uid) WHERE uid IS NOT NULL;\n\n\t\t\tCREATE TABLE kiosk_terminals (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tlocation TEXT,\n\t\t\t\ttoken_hash TEXT NOT NULL,\n\t\t\t\tpin_required INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\texpires_at INTEGER,\n\t\t\t\tlast_seen_at INTEGER,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t);\n\t\t`,\n\t},\n\t{\n\t\tversion: 6,\n\t\tname: \"users: personal PIN for the kiosk terminal\",\n\t\tsql: `ALTER TABLE users ADD COLUMN pin_hash TEXT;`,\n\t},\n\t{\n\t\tversion: 7,\n\t\tname: \"kiosk terminals: short lived sessions\",\n\t\tsql: `\n\t\t\tALTER TABLE kiosk_terminals ADD COLUMN session_hash TEXT;\n\t\t\tALTER TABLE kiosk_terminals ADD COLUMN session_expires_at INTEGER;\n\t\t`,\n\t},\n\t{\n\t\tversion: 8,\n\t\tname: \"defaults: time zone Europe/Zurich becomes Europe/Berlin\",\n\t\tsql: `\n\t\t\t-- The default time zone of the adapter is Europe/Berlin now. Migration 1 is append-only, so a new\n\t\t\t-- database already gets the new default with it; existing installations are adjusted here: every\n\t\t\t-- employee and every instance setting that still carries the old default is moved over. Values that\n\t\t\t-- were chosen deliberately keep their name \u2014 they only change when they are exactly the old default.\n\t\t\tUPDATE users SET timezone = 'Europe/Berlin' WHERE timezone = 'Europe/Zurich';\n\t\t\tUPDATE app_settings SET value = 'Europe/Berlin' WHERE key = 'timezone' AND value = 'Europe/Zurich';\n\t\t`,\n\t},\n\t{\n\t\tversion: 9,\n\t\tname: \"users: picture of the employee\",\n\t\tsql: `ALTER TABLE users ADD COLUMN avatar TEXT;`,\n\t},\n\t{\n\t\tversion: 10,\n\t\tname: \"drops the leftovers of the removed data import\",\n\t\trun: (db: Db): void => {\n\t\t\t// The adapter does not read data of another time tracking system any more, so the columns that existed\n\t\t\t// for it are dropped. Fresh databases never create them (migrations 1, 2 and 5 are clean), so every\n\t\t\t// step asks first: an upgraded database loses the columns, a fresh one has nothing to do.\n\t\t\tif (hasColumn(db, \"users\", \"legacy_sha1\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE users DROP COLUMN legacy_sha1\");\n\t\t\t}\n\t\t\tif (hasColumn(db, \"work_profiles\", \"legacy_source\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE work_profiles DROP COLUMN legacy_source\");\n\t\t\t}\n\n\t\t\t// rfid_tags carried the printed card number of that system as a second identifier. SQLite cannot drop\n\t\t\t// a column that a CHECK constraint mentions, so the table is rebuilt without it; existing rows keep\n\t\t\t// their id, their uid and their token hash, and the unique index on the uid is recreated.\n\t\t\tif (!hasColumn(db, \"rfid_tags\", \"legacy_code\")) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tdb.exec(`\n\t\t\t\tCREATE TABLE rfid_tags_new (\n\t\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\t\tuid TEXT,\n\t\t\t\t\ttoken_hash TEXT NOT NULL,\n\t\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE SET NULL,\n\t\t\t\t\tlabel TEXT,\n\t\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\t\texpires_at INTEGER,\n\t\t\t\t\tlast_used_at INTEGER,\n\t\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t\t);\n\t\t\t\tINSERT INTO rfid_tags_new (id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at)\n\t\t\t\t\tSELECT id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at\n\t\t\t\t\tFROM rfid_tags;\n\t\t\t\tDROP TABLE rfid_tags;\n\t\t\t\tALTER TABLE rfid_tags_new RENAME TO rfid_tags;\n\t\t\t\tCREATE UNIQUE INDEX idx_rfid_uid ON rfid_tags(uid) WHERE uid IS NOT NULL;\n\t\t\t`);\n\t\t},\n\t},\n\t{\n\t\tversion: 11,\n\t\tname: \"terminals: the employees of a terminal\",\n\t\tsql: `\n\t\t\t-- A terminal stands at one place \u2014 an office, a workshop \u2014 and is meant for the employees that work\n\t\t\t-- there. No row for a terminal means \"all employees\", so terminals that existed before keep working\n\t\t\t-- exactly as they did.\n\t\t\tCREATE TABLE terminal_users (\n\t\t\t\tterminal_id INTEGER NOT NULL REFERENCES kiosk_terminals(id) ON DELETE CASCADE,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (terminal_id, user_id)\n\t\t\t);\n\t\t\tCREATE INDEX idx_terminal_users_user ON terminal_users(user_id);\n\t\t`,\n\t},\n\t{\n\t\tversion: 12,\n\t\tname: \"work profiles: paid breaks\",\n\t\tsql: `ALTER TABLE work_profiles ADD COLUMN pause_paid INTEGER NOT NULL DEFAULT 0;`,\n\t},\n\t{\n\t\tversion: 13,\n\t\tname: \"work profiles: paid break minutes instead of the paid flag\",\n\t\trun: (db: Db): void => {\n\t\t\t// The flag became an amount: a company may pay a quarter of an hour and nothing more. A profile that had\n\t\t\t// the flag set keeps a fully paid break (a day has 1440 minutes), a fresh profile pays nothing at all.\n\t\t\tif (!hasColumn(db, \"work_profiles\", \"pause_paid_minutes\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE work_profiles ADD COLUMN pause_paid_minutes INTEGER NOT NULL DEFAULT 0\");\n\t\t\t}\n\t\t\tif (hasColumn(db, \"work_profiles\", \"pause_paid\")) {\n\t\t\t\tdb.exec(\"UPDATE work_profiles SET pause_paid_minutes = 1440 WHERE pause_paid = 1\");\n\t\t\t\tdb.exec(\"ALTER TABLE work_profiles DROP COLUMN pause_paid\");\n\t\t\t}\n\t\t},\n\t},\n\t{\n\t\tversion: 14,\n\t\tname: \"day aggregates: paid break minutes\",\n\t\tsql: `ALTER TABLE day_aggregates ADD COLUMN paid_break_min INTEGER NOT NULL DEFAULT 0;`,\n\t},\n\t{\n\t\tversion: 15,\n\t\tname: \"trigger rules: ioBroker states that punch or set the presence\",\n\t\tsql: `\n\t\t\t-- A rule watches a state of another adapter (a fingerprint reader, a button, a door contact) and turns a\n\t\t\t-- write on it into a punch or a presence change. Mode \"condition\" fires when the value matches\n\t\t\t-- \"condition\" and punches for \"user_id\"; mode \"user\" posts the employee the value names. \"last_fired_at\"\n\t\t\t-- keeps a chatty reader in check together with \"cooldown_sec\".\n\t\t\tCREATE TABLE trigger_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlabel TEXT,\n\t\t\t\tsource_state TEXT NOT NULL,\n\t\t\t\tmode TEXT NOT NULL DEFAULT 'condition'\n\t\t\t\t\tCHECK (mode IN ('condition','user')),\n\t\t\t\tcondition TEXT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\taction TEXT NOT NULL DEFAULT 'punch'\n\t\t\t\t\tCHECK (action IN ('punch','quickPunch','present','absent')),\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tcooldown_sec INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tlast_fired_at INTEGER,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE INDEX idx_trigger_rules_source ON trigger_rules(source_state);\n\t\t`,\n\t},\n\t{\n\t\tversion: 16,\n\t\tname: \"automation rules: what the adapter does on its own (clock out, reminders)\",\n\t\tsql: `\n\t\t\t-- A rule of the adapter itself, not of a foreign state: at a certain local time (clockOut, missingPunch)\n\t\t\t-- or after a while without a break (breakReminder) the adapter acts. \"user_id\" is NULL for every\n\t\t\t-- employee, so a company rule and a rule for one person can live side by side.\n\t\t\tCREATE TABLE automation_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlabel TEXT,\n\t\t\t\tkind TEXT NOT NULL\n\t\t\t\t\tCHECK (kind IN ('clockOut','missingPunch','breakReminder')),\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tat_minute INTEGER,\n\t\t\t\tafter_minutes INTEGER,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE INDEX idx_automation_rules_kind ON automation_rules(kind);\n\n\t\t\t-- What a rule already did: one row per rule, employee and local date keeps it at \"once a day\" and doubles\n\t\t\t-- as the log the administration shows. A punch that is already written is never written twice.\n\t\t\tCREATE TABLE automation_runs (\n\t\t\t\trule_id INTEGER NOT NULL REFERENCES automation_rules(id) ON DELETE CASCADE,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tlocal_date TEXT NOT NULL,\n\t\t\t\tfired_at INTEGER NOT NULL,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tPRIMARY KEY (rule_id, user_id, local_date)\n\t\t\t);\n\t\t\tCREATE INDEX idx_automation_runs_user ON automation_runs(user_id, local_date);\n\t\t`,\n\t},\n\t{\n\t\tversion: 17,\n\t\tname: \"automation rules: weekdays and a weekly instead of a daily guard\",\n\t\tsql: `\n\t\t\t-- A rule can be limited to certain weekdays: bit 0 is Monday \u2026 bit 6 is Sunday, 127 is every day.\n\t\t\tALTER TABLE automation_rules ADD COLUMN weekdays INTEGER NOT NULL DEFAULT 127;\n\n\t\t\t-- 'day' keeps the guard of one run per employee and date, 'week' allows one run per ISO week.\n\t\t\tALTER TABLE automation_rules ADD COLUMN repeat TEXT NOT NULL DEFAULT 'day';\n\n\t\t\t-- the guard column no longer holds a date only: for a weekly rule it is the ISO week (2026-W38), so the\n\t\t\t-- name says what it keeps. Existing rows stay valid \u2014 a date is the period of a daily rule.\n\t\t\tALTER TABLE automation_runs RENAME COLUMN local_date TO period;\n\t\t`,\n\t},\n\t{\n\t\tversion: 18,\n\t\tname: \"automation rules: the kind clockIn (punch in somebody who is missing)\",\n\t\tsql: `\n\t\t\t-- A fourth kind joins the rules: 'clockIn' punches an employee in who is still missing at the configured\n\t\t\t-- time (the decision makes sure nobody is clocked in already). SQLite cannot change a CHECK constraint in\n\t\t\t-- place, so the table is rebuilt \u2014 and because automation_runs belongs to it with ON DELETE CASCADE, the\n\t\t\t-- run log is copied first: dropping the parent would take it with it.\n\t\t\tCREATE TABLE automation_runs_copy (\n\t\t\t\trule_id INTEGER NOT NULL,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tperiod TEXT NOT NULL,\n\t\t\t\tfired_at INTEGER NOT NULL,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tPRIMARY KEY (rule_id, user_id, period)\n\t\t\t);\n\t\t\tINSERT INTO automation_runs_copy (rule_id, user_id, period, fired_at, action)\n\t\t\t\tSELECT rule_id, user_id, period, fired_at, action FROM automation_runs;\n\n\t\t\tCREATE TABLE automation_rules_new (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlabel TEXT,\n\t\t\t\tkind TEXT NOT NULL\n\t\t\t\t\tCHECK (kind IN ('clockIn','clockOut','missingPunch','breakReminder')),\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tat_minute INTEGER,\n\t\t\t\tafter_minutes INTEGER,\n\t\t\t\tweekdays INTEGER NOT NULL DEFAULT 127,\n\t\t\t\trepeat TEXT NOT NULL DEFAULT 'day',\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tINSERT INTO automation_rules_new\n\t\t\t\t(id, label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active, created_at, updated_at)\n\t\t\t\tSELECT id, label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active, created_at, updated_at\n\t\t\t\tFROM automation_rules;\n\n\t\t\tDROP TABLE automation_runs;\n\t\t\tDROP TABLE automation_rules;\n\t\t\tALTER TABLE automation_rules_new RENAME TO automation_rules;\n\t\t\t-- the runs keep their user reference; the reference to the rule is kept by the repository, which deletes\n\t\t\t-- the runs of a rule together with it\n\t\t\tALTER TABLE automation_runs_copy RENAME TO automation_runs;\n\n\t\t\tCREATE INDEX idx_automation_rules_kind ON automation_rules(kind);\n\t\t\tCREATE INDEX idx_automation_runs_user ON automation_runs(user_id, period);\n\t\t`,\n\t},\n\t{\n\t\tversion: 19,\n\t\tname: \"holidays: a stable key next to the name, so the app can translate the seeded days\",\n\t\tsql: `\n\t\t\t-- The seeded holidays carry an English name. The key says which day it is, so the web app shows it in the\n\t\t\t-- language of the display; a day somebody added by hand has no key and keeps its own name.\n\t\t\tALTER TABLE holidays ADD COLUMN key TEXT;\n\n\t\t\t-- the days that are already stored get their key from the date\n\t\t\tUPDATE holidays SET key = 'newYear' WHERE key IS NULL AND date LIKE '%-01-01';\n\t\t\tUPDATE holidays SET key = 'epiphany' WHERE key IS NULL AND date LIKE '%-01-06';\n\t\t\tUPDATE holidays SET key = 'labourDay' WHERE key IS NULL AND date LIKE '%-05-01';\n\t\t\tUPDATE holidays SET key = 'nationalDay' WHERE key IS NULL AND (date LIKE '%-08-01' OR date LIKE '%-10-26');\n\t\t\tUPDATE holidays SET key = 'assumption' WHERE key IS NULL AND date LIKE '%-08-15';\n\t\t\tUPDATE holidays SET key = 'germanUnity' WHERE key IS NULL AND date LIKE '%-10-03';\n\t\t\tUPDATE holidays SET key = 'allSaints' WHERE key IS NULL AND date LIKE '%-11-01';\n\t\t\tUPDATE holidays SET key = 'immaculateConception' WHERE key IS NULL AND date LIKE '%-12-08';\n\t\t\tUPDATE holidays SET key = 'christmas' WHERE key IS NULL AND date LIKE '%-12-25';\n\t\t\tUPDATE holidays SET key = 'boxingDay' WHERE key IS NULL AND date LIKE '%-12-26';\n\t\t\t-- the movable feasts carry their English name, which is unique enough to find them again\n\t\t\tUPDATE holidays SET key = 'goodFriday' WHERE key IS NULL AND name = 'Good Friday';\n\t\t\tUPDATE holidays SET key = 'easterMonday' WHERE key IS NULL AND name = 'Easter Monday';\n\t\t\tUPDATE holidays SET key = 'ascension' WHERE key IS NULL AND name = 'Ascension Day';\n\t\t\tUPDATE holidays SET key = 'whitMonday' WHERE key IS NULL AND name = 'Whit Monday';\n\t\t\tUPDATE holidays SET key = 'corpusChristi' WHERE key IS NULL AND name = 'Corpus Christi';\n\t\t`,\n\t},\n];\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["/**\n * Versioned database migrations.\n *\n * The schema follows the internal specification (section 2). Rules:\n * - Times are stored as UTC epoch seconds (INTEGER); durations as whole minutes.\n * - Local date/time fields (`ts_local`, `local_date`) are derived caches of `ts_utc` + user time zone.\n * - Migrations are never *reinterpreted*: a released migration keeps its effect for databases that already\n * ran it, and every schema change comes as a new migration. Definitions that only freshly created\n * databases ever see may be tidied up afterwards, as long as fresh and upgraded installations end up with\n * the same schema (see migration 10, which cleans up behind migrations 1, 2 and 5).\n */\n\nimport type { Db } from \"./database\";\n\n/**\n * A single schema migration: either plain SQL or a JavaScript step for changes that SQL cannot express\n * (e.g. \u201Cdrop this column, but only when it is still there\u201D).\n */\nexport interface Migration {\n\t/** Monotonically increasing version number, applied in ascending order */\n\tversion: number;\n\t/** Short description, stored in `schema_migrations` for traceability */\n\tname: string;\n\t/** SQL statements (may contain several statements separated by semicolons) */\n\tsql?: string;\n\t/** JavaScript steps, run inside the same transaction as `sql` */\n\trun?: (db: Db) => void;\n}\n\n/**\n * Checks whether a table has a column.\n *\n * Migrations that clean up after older versions use this to stay safe for both kinds of database: one that was\n * created by an older version and one that was created by the current one (which never creates the column).\n *\n * @param db - open database handle\n * @param table - table name\n * @param column - column name\n * @returns true when the column exists\n */\nexport function hasColumn(db: Db, table: string, column: string): boolean {\n\tconst rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];\n\treturn rows.some(row => row.name === column);\n}\n\nexport const migrations: Migration[] = [\n\t{\n\t\tversion: 1,\n\t\tname: \"core: users, roles, permissions, work profiles, shift and pause rules, settings, sessions, audit, import runs\",\n\t\tsql: `\n\t\t\tCREATE TABLE users (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlogin TEXT NOT NULL UNIQUE,\n\t\t\t\tpassword_hash TEXT NOT NULL DEFAULT '',\n\t\t\t\tdisplay_name TEXT NOT NULL,\n\t\t\t\temail TEXT,\n\t\t\t\trfid_card TEXT,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tmust_change_pw INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tlocale TEXT NOT NULL DEFAULT 'de-DE',\n\t\t\t\ttimezone TEXT NOT NULL DEFAULT 'Europe/Berlin',\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tlast_login_at INTEGER\n\t\t\t);\n\n\t\t\tCREATE TABLE roles (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tkey TEXT NOT NULL UNIQUE,\n\t\t\t\tname TEXT NOT NULL\n\t\t\t);\n\n\t\t\tCREATE TABLE permissions (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tkey TEXT NOT NULL UNIQUE\n\t\t\t);\n\n\t\t\tCREATE TABLE role_permissions (\n\t\t\t\trole_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,\n\t\t\t\tpermission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,\n\t\t\t\tPRIMARY KEY (role_id, permission_id)\n\t\t\t);\n\n\t\t\tCREATE TABLE user_roles (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\trole_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,\n\t\t\t\tPRIMARY KEY (user_id, role_id)\n\t\t\t);\n\n\t\t\tCREATE TABLE work_profiles (\n\t\t\t\tuser_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tpercent REAL NOT NULL DEFAULT 100,\n\t\t\t\tweekly_hours REAL NOT NULL DEFAULT 42.5,\n\t\t\t\tworkdays TEXT NOT NULL DEFAULT '0;1;1;1;1;1;0',\n\t\t\t\tstart_date INTEGER,\n\t\t\t\tend_date INTEGER,\n\t\t\t\tovertime_carryover INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvorholzeit_per_year INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvacation_carryover REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_per_year REAL NOT NULL DEFAULT 0,\n\t\t\t\tovertime_model TEXT NOT NULL DEFAULT 'monthly'\n\t\t\t\t CHECK (overtime_model IN ('cumulative','yearly','monthly')),\n\t\t\t\tholiday_flags TEXT\n\t\t\t);\n\n\t\t\tCREATE TABLE shift_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tday_of_week INTEGER CHECK (day_of_week IS NULL OR day_of_week BETWEEN 0 AND 6),\n\t\t\t\tfrom_min INTEGER,\n\t\t\t\tto_min INTEGER,\n\t\t\t\tsurcharge REAL NOT NULL DEFAULT 0,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1\n\t\t\t);\n\n\t\t\tCREATE TABLE pause_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tfrom_min INTEGER NOT NULL,\n\t\t\t\tto_min INTEGER,\n\t\t\t\tpause_min INTEGER NOT NULL,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tCHECK (to_min IS NULL OR to_min > from_min)\n\t\t\t);\n\n\t\t\tCREATE TABLE app_settings (\n\t\t\t\tkey TEXT PRIMARY KEY,\n\t\t\t\tvalue TEXT NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\n\t\t\tCREATE TABLE sessions (\n\t\t\t\tid TEXT PRIMARY KEY,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\texpires_at INTEGER NOT NULL,\n\t\t\t\tuser_agent TEXT,\n\t\t\t\tip TEXT,\n\t\t\t\trevoked INTEGER NOT NULL DEFAULT 0\n\t\t\t);\n\n\t\t\tCREATE TABLE audit_log (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tat_utc INTEGER NOT NULL,\n\t\t\t\tactor_id INTEGER,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tentity TEXT,\n\t\t\t\tentity_id TEXT,\n\t\t\t\tdetail TEXT,\n\t\t\t\tip TEXT\n\t\t\t);\n\n\t\t\tCREATE TABLE import_runs (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tsource TEXT NOT NULL,\n\t\t\t\tsource_path TEXT NOT NULL,\n\t\t\t\tstarted_at INTEGER NOT NULL,\n\t\t\t\tfinished_at INTEGER,\n\t\t\t\tmode TEXT NOT NULL DEFAULT 'dry-run'\n\t\t\t\t CHECK (mode IN ('dry-run','commit')),\n\t\t\t\tstatus TEXT NOT NULL DEFAULT 'running'\n\t\t\t\t CHECK (status IN ('running','ok','warnings','mismatch','failed')),\n\t\t\t\ttimezone_assumed TEXT,\n\t\t\t\tstats TEXT,\n\t\t\t\twarnings TEXT,\n\t\t\t\tactor_id INTEGER REFERENCES users(id)\n\t\t\t);\n\t\t`,\n\t},\n\t{\n\t\tversion: 2,\n\t\tname: \"time: entries, audit, day/month/year aggregates, payouts\",\n\t\tsql: `\n\t\t\tCREATE TABLE time_entries (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tts_utc INTEGER NOT NULL,\n\t\t\t\tclient_ts_utc INTEGER,\n\t\t\t\tts_local INTEGER NOT NULL,\n\t\t\t\tlocal_date TEXT NOT NULL,\n\t\t\t\tdirection TEXT NOT NULL DEFAULT 'auto'\n\t\t\t\t CHECK (direction IN ('in','out','auto')),\n\t\t\t\tsource TEXT NOT NULL DEFAULT 'web'\n\t\t\t\t CHECK (source IN ('web','nfc','terminal','api','admin','import')),\n\t\t\t\tidempotency_key TEXT,\n\t\t\t\tsync_state TEXT NOT NULL DEFAULT 'synced'\n\t\t\t\t CHECK (sync_state IN ('synced','pending','conflict')),\n\t\t\t\trevision INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tnote TEXT,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id),\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tupdated_by INTEGER REFERENCES users(id)\n\t\t\t);\n\n\t\t\tCREATE INDEX idx_entries_user_date ON time_entries(user_id, local_date);\n\t\t\tCREATE INDEX idx_entries_user_ts ON time_entries(user_id, ts_utc);\n\t\t\tCREATE UNIQUE INDEX idx_entries_idem\n\t\t\t\tON time_entries(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL;\n\n\t\t\tCREATE TABLE time_entry_audit (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tentry_id INTEGER,\n\t\t\t\tuser_id INTEGER NOT NULL,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tchanges TEXT,\n\t\t\t\told_ts_utc INTEGER,\n\t\t\t\tnew_ts_utc INTEGER,\n\t\t\t\trevision INTEGER,\n\t\t\t\treason TEXT,\n\t\t\t\tactor_id INTEGER NOT NULL,\n\t\t\t\tactor_ip TEXT,\n\t\t\t\tat_utc INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE INDEX idx_entry_audit_entry ON time_entry_audit(entry_id);\n\t\t`,\n\t},\n\t{\n\t\tversion: 3,\n\t\tname: \"aggregates: day, month, year and payouts\",\n\t\tsql: `\n\t\t\tCREATE TABLE day_aggregates (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tlocal_date TEXT NOT NULL,\n\t\t\t\tworked_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tbreak_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\ttarget_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tbalance_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tabsence_code TEXT,\n\t\t\t\tis_holiday INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tfirst_in_utc INTEGER,\n\t\t\t\tlast_out_utc INTEGER,\n\t\t\t\thas_open_entry INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (user_id, local_date)\n\t\t\t);\n\n\t\t\tCREATE TABLE month_aggregates (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tmonth INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),\n\t\t\t\tworked_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\ttarget_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tbalance_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tovertime_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvacation_used REAL NOT NULL DEFAULT 0,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (user_id, year, month)\n\t\t\t);\n\n\t\t\tCREATE TABLE year_aggregates (\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tworked_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\ttarget_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tovertime_min INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tovertime_start INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tvacation_days REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_used REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_left REAL NOT NULL DEFAULT 0,\n\t\t\t\tvacation_planned_days REAL NOT NULL DEFAULT 0,\n\t\t\t\tsettled INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (user_id, year)\n\t\t\t);\n\n\t\t\tCREATE TABLE payouts (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tmonth INTEGER,\n\t\t\t\tminutes INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tamount REAL,\n\t\t\t\tnote TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id)\n\t\t\t);\n\t\t\tCREATE INDEX idx_payouts_user ON payouts(user_id, year, month);\n\t\t`,\n\t},\n\t{\n\t\tversion: 4,\n\t\tname: \"absence: types, absences, holidays\",\n\t\tsql: `\n\t\t\tCREATE TABLE absence_types (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tcode TEXT NOT NULL,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tpaid INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tfactor REAL NOT NULL DEFAULT 100,\n\t\t\t\treduce_vacation INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1\n\t\t\t);\n\n\t\t\tCREATE UNIQUE INDEX idx_absence_types_global\n\t\t\t\tON absence_types(code) WHERE user_id IS NULL;\n\t\t\tCREATE UNIQUE INDEX idx_absence_types_user\n\t\t\t\tON absence_types(user_id, code) WHERE user_id IS NOT NULL;\n\n\t\t\tCREATE TABLE absences (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\ttype_id INTEGER NOT NULL REFERENCES absence_types(id),\n\t\t\t\tdate_from TEXT NOT NULL,\n\t\t\t\tdate_to TEXT NOT NULL,\n\t\t\t\tday_portion REAL NOT NULL DEFAULT 1 CHECK (day_portion > 0 AND day_portion <= 1),\n\t\t\t\thours REAL,\n\t\t\t\tstatus TEXT NOT NULL DEFAULT 'taken'\n\t\t\t\t CHECK (status IN ('taken','planned')),\n\t\t\t\tnote TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id),\n\t\t\t\tCHECK (date_from <= date_to)\n\t\t\t);\n\t\t\tCREATE INDEX idx_absences_user_dates ON absences(user_id, date_from, date_to);\n\n\t\t\tCREATE TABLE holidays (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tregion TEXT NOT NULL DEFAULT 'global',\n\t\t\t\tyear INTEGER NOT NULL,\n\t\t\t\tdate TEXT NOT NULL,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tUNIQUE (region, date)\n\t\t\t);\n\t\t\tCREATE INDEX idx_holidays_year ON holidays(year, region);\n\t\t`,\n\t},\n\t{\n\t\tversion: 5,\n\t\tname: \"devices: rfid tags and kiosk terminals\",\n\t\tsql: `\n\t\t\tCREATE TABLE rfid_tags (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tuid TEXT,\n\t\t\t\ttoken_hash TEXT NOT NULL,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE SET NULL,\n\t\t\t\tlabel TEXT,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\texpires_at INTEGER,\n\t\t\t\tlast_used_at INTEGER,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE UNIQUE INDEX idx_rfid_uid ON rfid_tags(uid) WHERE uid IS NOT NULL;\n\n\t\t\tCREATE TABLE kiosk_terminals (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tlocation TEXT,\n\t\t\t\ttoken_hash TEXT NOT NULL,\n\t\t\t\tpin_required INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\texpires_at INTEGER,\n\t\t\t\tlast_seen_at INTEGER,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t);\n\t\t`,\n\t},\n\t{\n\t\tversion: 6,\n\t\tname: \"users: personal PIN for the kiosk terminal\",\n\t\tsql: `ALTER TABLE users ADD COLUMN pin_hash TEXT;`,\n\t},\n\t{\n\t\tversion: 7,\n\t\tname: \"kiosk terminals: short lived sessions\",\n\t\tsql: `\n\t\t\tALTER TABLE kiosk_terminals ADD COLUMN session_hash TEXT;\n\t\t\tALTER TABLE kiosk_terminals ADD COLUMN session_expires_at INTEGER;\n\t\t`,\n\t},\n\t{\n\t\tversion: 8,\n\t\tname: \"defaults: time zone Europe/Zurich becomes Europe/Berlin\",\n\t\tsql: `\n\t\t\t-- The default time zone of the adapter is Europe/Berlin now. Migration 1 is append-only, so a new\n\t\t\t-- database already gets the new default with it; existing installations are adjusted here: every\n\t\t\t-- employee and every instance setting that still carries the old default is moved over. Values that\n\t\t\t-- were chosen deliberately keep their name \u2014 they only change when they are exactly the old default.\n\t\t\tUPDATE users SET timezone = 'Europe/Berlin' WHERE timezone = 'Europe/Zurich';\n\t\t\tUPDATE app_settings SET value = 'Europe/Berlin' WHERE key = 'timezone' AND value = 'Europe/Zurich';\n\t\t`,\n\t},\n\t{\n\t\tversion: 9,\n\t\tname: \"users: picture of the employee\",\n\t\tsql: `ALTER TABLE users ADD COLUMN avatar TEXT;`,\n\t},\n\t{\n\t\tversion: 10,\n\t\tname: \"drops the leftovers of the removed data import\",\n\t\trun: (db: Db): void => {\n\t\t\t// The adapter does not read data of another time tracking system any more, so the columns that existed\n\t\t\t// for it are dropped. Fresh databases never create them (migrations 1, 2 and 5 are clean), so every\n\t\t\t// step asks first: an upgraded database loses the columns, a fresh one has nothing to do.\n\t\t\tif (hasColumn(db, \"users\", \"legacy_sha1\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE users DROP COLUMN legacy_sha1\");\n\t\t\t}\n\t\t\tif (hasColumn(db, \"work_profiles\", \"legacy_source\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE work_profiles DROP COLUMN legacy_source\");\n\t\t\t}\n\n\t\t\t// rfid_tags carried the printed card number of that system as a second identifier. SQLite cannot drop\n\t\t\t// a column that a CHECK constraint mentions, so the table is rebuilt without it; existing rows keep\n\t\t\t// their id, their uid and their token hash, and the unique index on the uid is recreated.\n\t\t\tif (!hasColumn(db, \"rfid_tags\", \"legacy_code\")) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tdb.exec(`\n\t\t\t\tCREATE TABLE rfid_tags_new (\n\t\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\t\tuid TEXT,\n\t\t\t\t\ttoken_hash TEXT NOT NULL,\n\t\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE SET NULL,\n\t\t\t\t\tlabel TEXT,\n\t\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\t\texpires_at INTEGER,\n\t\t\t\t\tlast_used_at INTEGER,\n\t\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t\t);\n\t\t\t\tINSERT INTO rfid_tags_new (id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at)\n\t\t\t\t\tSELECT id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at\n\t\t\t\t\tFROM rfid_tags;\n\t\t\t\tDROP TABLE rfid_tags;\n\t\t\t\tALTER TABLE rfid_tags_new RENAME TO rfid_tags;\n\t\t\t\tCREATE UNIQUE INDEX idx_rfid_uid ON rfid_tags(uid) WHERE uid IS NOT NULL;\n\t\t\t`);\n\t\t},\n\t},\n\t{\n\t\tversion: 11,\n\t\tname: \"terminals: the employees of a terminal\",\n\t\tsql: `\n\t\t\t-- A terminal stands at one place \u2014 an office, a workshop \u2014 and is meant for the employees that work\n\t\t\t-- there. No row for a terminal means \"all employees\", so terminals that existed before keep working\n\t\t\t-- exactly as they did.\n\t\t\tCREATE TABLE terminal_users (\n\t\t\t\tterminal_id INTEGER NOT NULL REFERENCES kiosk_terminals(id) ON DELETE CASCADE,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tPRIMARY KEY (terminal_id, user_id)\n\t\t\t);\n\t\t\tCREATE INDEX idx_terminal_users_user ON terminal_users(user_id);\n\t\t`,\n\t},\n\t{\n\t\tversion: 12,\n\t\tname: \"work profiles: paid breaks\",\n\t\tsql: `ALTER TABLE work_profiles ADD COLUMN pause_paid INTEGER NOT NULL DEFAULT 0;`,\n\t},\n\t{\n\t\tversion: 13,\n\t\tname: \"work profiles: paid break minutes instead of the paid flag\",\n\t\trun: (db: Db): void => {\n\t\t\t// The flag became an amount: a company may pay a quarter of an hour and nothing more. A profile that had\n\t\t\t// the flag set keeps a fully paid break (a day has 1440 minutes), a fresh profile pays nothing at all.\n\t\t\tif (!hasColumn(db, \"work_profiles\", \"pause_paid_minutes\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE work_profiles ADD COLUMN pause_paid_minutes INTEGER NOT NULL DEFAULT 0\");\n\t\t\t}\n\t\t\tif (hasColumn(db, \"work_profiles\", \"pause_paid\")) {\n\t\t\t\tdb.exec(\"UPDATE work_profiles SET pause_paid_minutes = 1440 WHERE pause_paid = 1\");\n\t\t\t\tdb.exec(\"ALTER TABLE work_profiles DROP COLUMN pause_paid\");\n\t\t\t}\n\t\t},\n\t},\n\t{\n\t\tversion: 14,\n\t\tname: \"day aggregates: paid break minutes\",\n\t\tsql: `ALTER TABLE day_aggregates ADD COLUMN paid_break_min INTEGER NOT NULL DEFAULT 0;`,\n\t},\n\t{\n\t\tversion: 15,\n\t\tname: \"trigger rules: ioBroker states that punch or set the presence\",\n\t\tsql: `\n\t\t\t-- A rule watches a state of another adapter (a fingerprint reader, a button, a door contact) and turns a\n\t\t\t-- write on it into a punch or a presence change. Mode \"condition\" fires when the value matches\n\t\t\t-- \"condition\" and punches for \"user_id\"; mode \"user\" posts the employee the value names. \"last_fired_at\"\n\t\t\t-- keeps a chatty reader in check together with \"cooldown_sec\".\n\t\t\tCREATE TABLE trigger_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlabel TEXT,\n\t\t\t\tsource_state TEXT NOT NULL,\n\t\t\t\tmode TEXT NOT NULL DEFAULT 'condition'\n\t\t\t\t\tCHECK (mode IN ('condition','user')),\n\t\t\t\tcondition TEXT,\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\taction TEXT NOT NULL DEFAULT 'punch'\n\t\t\t\t\tCHECK (action IN ('punch','quickPunch','present','absent')),\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tcooldown_sec INTEGER NOT NULL DEFAULT 0,\n\t\t\t\tlast_fired_at INTEGER,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE INDEX idx_trigger_rules_source ON trigger_rules(source_state);\n\t\t`,\n\t},\n\t{\n\t\tversion: 16,\n\t\tname: \"automation rules: what the adapter does on its own (clock out, reminders)\",\n\t\tsql: `\n\t\t\t-- A rule of the adapter itself, not of a foreign state: at a certain local time (clockOut, missingPunch)\n\t\t\t-- or after a while without a break (breakReminder) the adapter acts. \"user_id\" is NULL for every\n\t\t\t-- employee, so a company rule and a rule for one person can live side by side.\n\t\t\tCREATE TABLE automation_rules (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlabel TEXT,\n\t\t\t\tkind TEXT NOT NULL\n\t\t\t\t\tCHECK (kind IN ('clockOut','missingPunch','breakReminder')),\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tat_minute INTEGER,\n\t\t\t\tafter_minutes INTEGER,\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tCREATE INDEX idx_automation_rules_kind ON automation_rules(kind);\n\n\t\t\t-- What a rule already did: one row per rule, employee and local date keeps it at \"once a day\" and doubles\n\t\t\t-- as the log the administration shows. A punch that is already written is never written twice.\n\t\t\tCREATE TABLE automation_runs (\n\t\t\t\trule_id INTEGER NOT NULL REFERENCES automation_rules(id) ON DELETE CASCADE,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tlocal_date TEXT NOT NULL,\n\t\t\t\tfired_at INTEGER NOT NULL,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tPRIMARY KEY (rule_id, user_id, local_date)\n\t\t\t);\n\t\t\tCREATE INDEX idx_automation_runs_user ON automation_runs(user_id, local_date);\n\t\t`,\n\t},\n\t{\n\t\tversion: 17,\n\t\tname: \"automation rules: weekdays and a weekly instead of a daily guard\",\n\t\tsql: `\n\t\t\t-- A rule can be limited to certain weekdays: bit 0 is Monday \u2026 bit 6 is Sunday, 127 is every day.\n\t\t\tALTER TABLE automation_rules ADD COLUMN weekdays INTEGER NOT NULL DEFAULT 127;\n\n\t\t\t-- 'day' keeps the guard of one run per employee and date, 'week' allows one run per ISO week.\n\t\t\tALTER TABLE automation_rules ADD COLUMN repeat TEXT NOT NULL DEFAULT 'day';\n\n\t\t\t-- the guard column no longer holds a date only: for a weekly rule it is the ISO week (2026-W38), so the\n\t\t\t-- name says what it keeps. Existing rows stay valid \u2014 a date is the period of a daily rule.\n\t\t\tALTER TABLE automation_runs RENAME COLUMN local_date TO period;\n\t\t`,\n\t},\n\t{\n\t\tversion: 18,\n\t\tname: \"automation rules: the kind clockIn (punch in somebody who is missing)\",\n\t\tsql: `\n\t\t\t-- A fourth kind joins the rules: 'clockIn' punches an employee in who is still missing at the configured\n\t\t\t-- time (the decision makes sure nobody is clocked in already). SQLite cannot change a CHECK constraint in\n\t\t\t-- place, so the table is rebuilt \u2014 and because automation_runs belongs to it with ON DELETE CASCADE, the\n\t\t\t-- run log is copied first: dropping the parent would take it with it.\n\t\t\tCREATE TABLE automation_runs_copy (\n\t\t\t\trule_id INTEGER NOT NULL,\n\t\t\t\tuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tperiod TEXT NOT NULL,\n\t\t\t\tfired_at INTEGER NOT NULL,\n\t\t\t\taction TEXT NOT NULL,\n\t\t\t\tPRIMARY KEY (rule_id, user_id, period)\n\t\t\t);\n\t\t\tINSERT INTO automation_runs_copy (rule_id, user_id, period, fired_at, action)\n\t\t\t\tSELECT rule_id, user_id, period, fired_at, action FROM automation_runs;\n\n\t\t\tCREATE TABLE automation_rules_new (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tlabel TEXT,\n\t\t\t\tkind TEXT NOT NULL\n\t\t\t\t\tCHECK (kind IN ('clockIn','clockOut','missingPunch','breakReminder')),\n\t\t\t\tuser_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n\t\t\t\tat_minute INTEGER,\n\t\t\t\tafter_minutes INTEGER,\n\t\t\t\tweekdays INTEGER NOT NULL DEFAULT 127,\n\t\t\t\trepeat TEXT NOT NULL DEFAULT 'day',\n\t\t\t\tis_active INTEGER NOT NULL DEFAULT 1,\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_at INTEGER NOT NULL\n\t\t\t);\n\t\t\tINSERT INTO automation_rules_new\n\t\t\t\t(id, label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active, created_at, updated_at)\n\t\t\t\tSELECT id, label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active, created_at, updated_at\n\t\t\t\tFROM automation_rules;\n\n\t\t\tDROP TABLE automation_runs;\n\t\t\tDROP TABLE automation_rules;\n\t\t\tALTER TABLE automation_rules_new RENAME TO automation_rules;\n\t\t\t-- the runs keep their user reference; the reference to the rule is kept by the repository, which deletes\n\t\t\t-- the runs of a rule together with it\n\t\t\tALTER TABLE automation_runs_copy RENAME TO automation_runs;\n\n\t\t\tCREATE INDEX idx_automation_rules_kind ON automation_rules(kind);\n\t\t\tCREATE INDEX idx_automation_runs_user ON automation_runs(user_id, period);\n\t\t`,\n\t},\n\t{\n\t\tversion: 19,\n\t\tname: \"holidays: a stable key next to the name, so the app can translate the seeded days\",\n\t\tsql: `\n\t\t\t-- The seeded holidays carry an English name. The key says which day it is, so the web app shows it in the\n\t\t\t-- language of the display; a day somebody added by hand has no key and keeps its own name.\n\t\t\tALTER TABLE holidays ADD COLUMN key TEXT;\n\n\t\t\t-- the days that are already stored get their key from the date\n\t\t\tUPDATE holidays SET key = 'newYear' WHERE key IS NULL AND date LIKE '%-01-01';\n\t\t\tUPDATE holidays SET key = 'epiphany' WHERE key IS NULL AND date LIKE '%-01-06';\n\t\t\tUPDATE holidays SET key = 'labourDay' WHERE key IS NULL AND date LIKE '%-05-01';\n\t\t\tUPDATE holidays SET key = 'nationalDay' WHERE key IS NULL AND (date LIKE '%-08-01' OR date LIKE '%-10-26');\n\t\t\tUPDATE holidays SET key = 'assumption' WHERE key IS NULL AND date LIKE '%-08-15';\n\t\t\tUPDATE holidays SET key = 'germanUnity' WHERE key IS NULL AND date LIKE '%-10-03';\n\t\t\tUPDATE holidays SET key = 'allSaints' WHERE key IS NULL AND date LIKE '%-11-01';\n\t\t\tUPDATE holidays SET key = 'immaculateConception' WHERE key IS NULL AND date LIKE '%-12-08';\n\t\t\tUPDATE holidays SET key = 'christmas' WHERE key IS NULL AND date LIKE '%-12-25';\n\t\t\tUPDATE holidays SET key = 'boxingDay' WHERE key IS NULL AND date LIKE '%-12-26';\n\t\t\t-- the movable feasts carry their English name, which is unique enough to find them again\n\t\t\tUPDATE holidays SET key = 'goodFriday' WHERE key IS NULL AND name = 'Good Friday';\n\t\t\tUPDATE holidays SET key = 'easterMonday' WHERE key IS NULL AND name = 'Easter Monday';\n\t\t\tUPDATE holidays SET key = 'ascension' WHERE key IS NULL AND name = 'Ascension Day';\n\t\t\tUPDATE holidays SET key = 'whitMonday' WHERE key IS NULL AND name = 'Whit Monday';\n\t\t\tUPDATE holidays SET key = 'corpusChristi' WHERE key IS NULL AND name = 'Corpus Christi';\n\t\t`,\n\t},\n\t{\n\t\tversion: 20,\n\t\tname: \"work profiles: whether the presence card shows the working time of the day\",\n\t\tsql: `\n\t\t\t-- The presence screen is visible before the PIN is entered, so showing the worked time of an employee\n\t\t\t-- is a decision of the administration, per employee. Off by default.\n\t\t\tALTER TABLE work_profiles ADD COLUMN show_worked_time INTEGER NOT NULL DEFAULT 0;\n\t\t`,\n\t},\n];\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -84,13 +84,15 @@ function mapWorkProfileRow(row) {
|
|
|
84
84
|
vacationPerYear: row.vacation_per_year,
|
|
85
85
|
overtimeModel: row.overtime_model,
|
|
86
86
|
holidayFlags: row.holiday_flags,
|
|
87
|
-
pausePaidMinutes: row.pause_paid_minutes
|
|
87
|
+
pausePaidMinutes: row.pause_paid_minutes,
|
|
88
|
+
showWorkedTime: row.show_worked_time !== 0
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
const USER_COLUMNS = `id, login, password_hash, display_name, email, rfid_card, is_active,
|
|
91
92
|
must_change_pw, locale, timezone, pin_hash, avatar, created_at, updated_at, last_login_at`;
|
|
92
93
|
const PROFILE_COLUMNS = `user_id, percent, weekly_hours, workdays, start_date, end_date, overtime_carryover,
|
|
93
|
-
vorholzeit_per_year, vacation_carryover, vacation_per_year, overtime_model, holiday_flags, pause_paid_minutes
|
|
94
|
+
vorholzeit_per_year, vacation_carryover, vacation_per_year, overtime_model, holiday_flags, pause_paid_minutes,
|
|
95
|
+
show_worked_time`;
|
|
94
96
|
const AUDITED_USER_FIELDS = [
|
|
95
97
|
"login",
|
|
96
98
|
"displayName",
|
|
@@ -113,7 +115,8 @@ const AUDITED_PROFILE_FIELDS = [
|
|
|
113
115
|
"vacationPerYear",
|
|
114
116
|
"overtimeModel",
|
|
115
117
|
"holidayFlags",
|
|
116
|
-
"pausePaidMinutes"
|
|
118
|
+
"pausePaidMinutes",
|
|
119
|
+
"showWorkedTime"
|
|
117
120
|
];
|
|
118
121
|
function createUsersRepository(db) {
|
|
119
122
|
const selectById = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE id = ?`);
|
|
@@ -141,7 +144,7 @@ function createUsersRepository(db) {
|
|
|
141
144
|
const selectProfile = db.prepare(`SELECT ${PROFILE_COLUMNS} FROM work_profiles WHERE user_id = ?`);
|
|
142
145
|
const upsertProfile = db.prepare(
|
|
143
146
|
`INSERT INTO work_profiles (${PROFILE_COLUMNS})
|
|
144
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
145
148
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
146
149
|
percent = excluded.percent,
|
|
147
150
|
weekly_hours = excluded.weekly_hours,
|
|
@@ -154,7 +157,8 @@ function createUsersRepository(db) {
|
|
|
154
157
|
vacation_per_year = excluded.vacation_per_year,
|
|
155
158
|
overtime_model = excluded.overtime_model,
|
|
156
159
|
holiday_flags = excluded.holiday_flags,
|
|
157
|
-
pause_paid_minutes = excluded.pause_paid_minutes
|
|
160
|
+
pause_paid_minutes = excluded.pause_paid_minutes,
|
|
161
|
+
show_worked_time = excluded.show_worked_time`
|
|
158
162
|
);
|
|
159
163
|
const selectRoles = db.prepare(
|
|
160
164
|
`SELECT r.key FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? ORDER BY r.key`
|
|
@@ -196,7 +200,8 @@ function createUsersRepository(db) {
|
|
|
196
200
|
vacationPerYear: 0,
|
|
197
201
|
overtimeModel: "monthly",
|
|
198
202
|
holidayFlags: null,
|
|
199
|
-
pausePaidMinutes: 0
|
|
203
|
+
pausePaidMinutes: 0,
|
|
204
|
+
showWorkedTime: false
|
|
200
205
|
});
|
|
201
206
|
const resolveRoleIds = (roleKeys) => roleKeys.map((key) => {
|
|
202
207
|
const row = selectRoleId.get(key);
|
|
@@ -456,7 +461,8 @@ function createUsersRepository(db) {
|
|
|
456
461
|
next.vacationPerYear,
|
|
457
462
|
next.overtimeModel,
|
|
458
463
|
next.holidayFlags,
|
|
459
|
-
next.pausePaidMinutes
|
|
464
|
+
next.pausePaidMinutes,
|
|
465
|
+
next.showWorkedTime ? 1 : 0
|
|
460
466
|
);
|
|
461
467
|
(0, import_audit.writeAuditLog)(db, {
|
|
462
468
|
atUtc: now,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/lib/db/repositories/users.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * User repository (`users`, `user_roles`, `work_profiles`).\n *\n * Every write records an audit entry in the same transaction; passwords and password hashes never end up\n * in the audit trail, only the fact that they were changed.\n */\n\nimport type { Db } from \"../database\";\nimport type { OvertimeModel } from \"../../domain/calculation\";\nimport type { WorkProfile } from \"../../domain/target\";\nimport { NotFoundError, ValidationError } from \"../../errors\";\nimport { writeAuditLog, diffFields } from \"./audit\";\n\n/** A user as stored in the database. */\nexport interface UserRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Unique login name */\n\tlogin: string;\n\t/** Hash of the password (never leaves the server) */\n\tpasswordHash: string;\n\t/** Name shown in the UI */\n\tdisplayName: string;\n\t/** E-mail address */\n\temail: string | null;\n\t/** RFID card id */\n\trfidCard: string | null;\n\t/** False for deactivated accounts */\n\tisActive: boolean;\n\t/** True while the user has to set a new password */\n\tmustChangePw: boolean;\n\t/** Preferred language */\n\tlocale: string;\n\t/** IANA time zone of the user */\n\ttimezone: string;\n\t/** Hash of the personal kiosk PIN, `null` when none is set */\n\tpinHash: string | null;\n\t/** Picture of the employee as a data URL, `null` when none is stored */\n\tavatar: string | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n\t/** Instant of the last change, UTC epoch seconds */\n\tupdatedAt: number;\n\t/** Instant of the last successful login, `null` if never logged in */\n\tlastLoginAt: number | null;\n}\n\n/** Employment parameters of a user (`work_profiles`). */\nexport interface WorkProfileRecord extends WorkProfile {\n\t/** Owner of the profile */\n\tuserId: number;\n\t/** Employment level in percent */\n\tpercent: number;\n\t/** Contracted hours per week at 100 % */\n\tweeklyHours: number;\n\t/** Active working days, `0;1;\u2026` for Sunday\u2026Saturday */\n\tworkdays: string;\n\t/** First day of the employment (UTC epoch seconds) */\n\tstartDate: number | null;\n\t/** Last day of the employment (UTC epoch seconds) */\n\tendDate: number | null;\n\t/** Overtime taken over from the previous year, in minutes */\n\tovertimeCarryover: number;\n\t/** Annual \u201CVorholzeit\u201D in minutes */\n\tvorholzeitPerYear: number;\n\t/** Vacation days taken over from the previous year */\n\tvacationCarryover: number;\n\t/** Annual vacation entitlement in days */\n\tvacationPerYear: number;\n\t/** Overtime model used for the reports */\n\tovertimeModel: OvertimeModel;\n\t/** Country specific holiday flags (JSON), `null` = instance default */\n\tholidayFlags: string | null;\n\t/** Minutes of the break per day that are paid (0 = the break is not paid at all) */\n\tpausePaidMinutes: number;\n}\n\n/** Input for creating a user. */\nexport interface CreateUserInput {\n\t/** Unique login name */\n\tlogin: string;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** Password hash (already hashed by the caller) */\n\tpasswordHash?: string;\n\t/** E-mail address */\n\temail?: string | null;\n\t/** Preferred language, default `de-DE` */\n\tlocale?: string;\n\t/** IANA time zone, default `Europe/Berlin` */\n\ttimezone?: string;\n\t/** RFID card id */\n\trfidCard?: string | null;\n\t/** Forces a password change on the next login */\n\tmustChangePw?: boolean;\n\t/** Roles to assign (all permissions are granted by the seed roles) */\n\troleKeys?: string[];\n\t/** Id of the user who is an additional employee of the new account (self service) */\n\tactorId?: number | null;\n\t/** Client IP address */\n\tactorIp?: string | null;\n\t/** Instant of creation, defaults to now */\n\tnow?: number;\n}\n\n/** Input for changing a user. */\nexport interface UpdateUserInput {\n\t/** Id of the changed user */\n\tid: number;\n\t/** Fields to change */\n\tpatch: {\n\t\tdisplayName?: string;\n\t\temail?: string | null;\n\t\tlocale?: string;\n\t\ttimezone?: string;\n\t\trfidCard?: string | null;\n\t\tisActive?: boolean;\n\t\tmustChangePw?: boolean;\n\t\t/** Password hash (already hashed by the caller) */\n\t\tpasswordHash?: string;\n\t};\n\t/** Reason of the change (recorded in the audit trail) */\n\treason?: string | null;\n\t/** Who performs the change */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Instant of the change, defaults to now */\n\tnow?: number;\n}\n\n/** Thrown when a login name is already taken. */\nexport class LoginExistsError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param login - login name that is already taken\n\t */\n\tconstructor(public readonly login: string) {\n\t\tsuper(`login \"${login}\" already exists`);\n\t\tthis.name = \"LoginExistsError\";\n\t}\n}\n\n/** Thrown when a role key is not known. */\nexport class UnknownRoleError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param roleKey - unknown role key\n\t */\n\tconstructor(public readonly roleKey: string) {\n\t\tsuper(`unknown role \"${roleKey}\"`);\n\t\tthis.name = \"UnknownRoleError\";\n\t}\n}\n\n/** User storage operations. */\nexport interface UsersRepository {\n\t/** Creates a user including a default work profile */\n\tcreate(input: CreateUserInput): UserRecord;\n\t/** Reads a user by id */\n\tfindById(id: number): UserRecord | null;\n\t/** Reads a user by login (case insensitive) */\n\tfindByLogin(login: string): UserRecord | null;\n\t/** Reads a user by the RFID card of the badge terminal */\n\tfindByRfidCard(card: string): UserRecord | null;\n\t/** Stores or clears the personal kiosk PIN (never written to the audit trail) */\n\tsetPin(input: {\n\t\tuserId: number;\n\t\tpinHash: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): void;\n\t/** Stores or clears the picture of an employee (the image itself never reaches the audit trail) */\n\tsetAvatar(input: {\n\t\t/** Id of the employee */\n\t\tuserId: number;\n\t\t/** Data URL of the picture, `null` clears it */\n\t\tavatar: string | null;\n\t\t/** Who changes the picture */\n\t\tactorId: number;\n\t\t/** Client IP address of the actor */\n\t\tactorIp?: string | null;\n\t\t/** Instant of the change, defaults to now */\n\t\tnow?: number;\n\t}): void;\n\t/** All users, optionally including the deactivated ones */\n\tlist(options?: { includeInactive?: boolean }): UserRecord[];\n\t/** Changes a user and audits the changed fields */\n\tupdate(input: UpdateUserInput): UserRecord;\n\t/** Role keys of a user (sorted) */\n\troles(userId: number): string[];\n\t/** All roles with their permission keys, sorted by key */\n\troleCatalog(): { key: string; name: string; permissions: string[] }[];\n\t/** Replaces the roles of a user */\n\tsetRoles(input: {\n\t\tuserId: number;\n\t\troleKeys: string[];\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): string[];\n\t/** Effective permission keys of a user (sorted) */\n\tpermissions(userId: number): string[];\n\t/** Checks whether a user holds all given permissions */\n\thasPermissions(userId: number, required: string[]): boolean;\n\t/** Work profile of a user, `null` when none is stored */\n\tgetWorkProfile(userId: number): WorkProfileRecord | null;\n\t/** Creates or updates the work profile */\n\tsaveWorkProfile(input: {\n\t\tuserId: number;\n\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): WorkProfileRecord;\n\t/** Stores the instant of the last successful login */\n\trecordLogin(userId: number, now?: number): void;\n}\n\ninterface UserRow {\n\tid: number;\n\tlogin: string;\n\tpassword_hash: string;\n\tdisplay_name: string;\n\temail: string | null;\n\trfid_card: string | null;\n\tis_active: number;\n\tmust_change_pw: number;\n\tlocale: string;\n\ttimezone: string;\n\tpin_hash: string | null;\n\tavatar: string | null;\n\tcreated_at: number;\n\tupdated_at: number;\n\tlast_login_at: number | null;\n}\n\ninterface WorkProfileRow {\n\tuser_id: number;\n\tpercent: number;\n\tweekly_hours: number;\n\tworkdays: string;\n\tstart_date: number | null;\n\tend_date: number | null;\n\tovertime_carryover: number;\n\tvorholzeit_per_year: number;\n\tvacation_carryover: number;\n\tvacation_per_year: number;\n\tovertime_model: OvertimeModel;\n\tholiday_flags: string | null;\n\tpause_paid_minutes: number;\n}\n\n/**\n * Maps a database row to a user record.\n *\n * @param row - raw database row\n * @returns user record\n */\nexport function mapUserRow(row: UserRow): UserRecord {\n\treturn {\n\t\tid: row.id,\n\t\tlogin: row.login,\n\t\tpasswordHash: row.password_hash,\n\t\tdisplayName: row.display_name,\n\t\temail: row.email,\n\t\trfidCard: row.rfid_card,\n\t\tisActive: row.is_active !== 0,\n\t\tmustChangePw: row.must_change_pw !== 0,\n\t\tlocale: row.locale,\n\t\ttimezone: row.timezone,\n\t\tpinHash: row.pin_hash,\n\t\tavatar: row.avatar,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t\tlastLoginAt: row.last_login_at,\n\t};\n}\n\n/**\n * Maps a database row to a work profile record.\n *\n * @param row - raw database row\n * @returns work profile record\n */\nexport function mapWorkProfileRow(row: WorkProfileRow): WorkProfileRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tpercent: row.percent,\n\t\tweeklyHours: row.weekly_hours,\n\t\tworkdays: row.workdays,\n\t\tstartDate: row.start_date,\n\t\tendDate: row.end_date,\n\t\tovertimeCarryover: row.overtime_carryover,\n\t\tvorholzeitPerYear: row.vorholzeit_per_year,\n\t\tvacationCarryover: row.vacation_carryover,\n\t\tvacationPerYear: row.vacation_per_year,\n\t\tovertimeModel: row.overtime_model,\n\t\tholidayFlags: row.holiday_flags,\n\t\tpausePaidMinutes: row.pause_paid_minutes,\n\t};\n}\n\nconst USER_COLUMNS = `id, login, password_hash, display_name, email, rfid_card, is_active,\n\\tmust_change_pw, locale, timezone, pin_hash, avatar, created_at, updated_at, last_login_at`;\n\nconst PROFILE_COLUMNS = `user_id, percent, weekly_hours, workdays, start_date, end_date, overtime_carryover,\n\\tvorholzeit_per_year, vacation_carryover, vacation_per_year, overtime_model, holiday_flags, pause_paid_minutes`;\n\n/** Field names of a user that are compared for the audit trail (the password is reported separately). */\nconst AUDITED_USER_FIELDS: (keyof UserRecord)[] = [\n\t\"login\",\n\t\"displayName\",\n\t\"email\",\n\t\"rfidCard\",\n\t\"isActive\",\n\t\"mustChangePw\",\n\t\"locale\",\n\t\"timezone\",\n];\n\n/** Field names of a work profile that are compared for the audit trail. */\n/** Field names of a work profile that are compared for the audit trail. */\nconst AUDITED_PROFILE_FIELDS: (keyof WorkProfileRecord)[] = [\n\t\"percent\",\n\t\"weeklyHours\",\n\t\"workdays\",\n\t\"startDate\",\n\t\"endDate\",\n\t\"overtimeCarryover\",\n\t\"vorholzeitPerYear\",\n\t\"vacationCarryover\",\n\t\"vacationPerYear\",\n\t\"overtimeModel\",\n\t\"holidayFlags\",\n\t\"pausePaidMinutes\",\n];\n\n/**\n * Creates the user repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createUsersRepository(db: Db): UsersRepository {\n\tconst selectById = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE id = ?`);\n\tconst selectByLogin = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE login = ? COLLATE NOCASE`);\n\tconst selectByRfid = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE rfid_card = ? COLLATE NOCASE LIMIT 1`);\n\tconst setPinHash = db.prepare(\"UPDATE users SET pin_hash = ?, updated_at = ? WHERE id = ?\");\n\tconst setAvatarData = db.prepare(\"UPDATE users SET avatar = ?, updated_at = ? WHERE id = ?\");\n\tconst selectAll = db.prepare(`SELECT ${USER_COLUMNS} FROM users ORDER BY display_name COLLATE NOCASE, id`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${USER_COLUMNS} FROM users WHERE is_active = 1 ORDER BY display_name COLLATE NOCASE, id`,\n\t);\n\tconst insertUser = db.prepare(\n\t\t`INSERT INTO users\n\t\t (login, password_hash, display_name, email, rfid_card, is_active, must_change_pw,\n\t\t locale, timezone, created_at, updated_at)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateUser = db.prepare(\n\t\t`UPDATE users SET display_name = ?, email = ?, locale = ?, timezone = ?, rfid_card = ?,\n\t\t is_active = ?, must_change_pw = ?, password_hash = ?, updated_at = ?\n\t\t WHERE id = ?`,\n\t);\n\tconst updateLastLogin = db.prepare(\"UPDATE users SET last_login_at = ? WHERE id = ?\");\n\tconst insertDefaultProfile = db.prepare(\"INSERT OR IGNORE INTO work_profiles (user_id) VALUES (?)\");\n\tconst selectProfile = db.prepare(`SELECT ${PROFILE_COLUMNS} FROM work_profiles WHERE user_id = ?`);\n\tconst upsertProfile = db.prepare(\n\t\t`INSERT INTO work_profiles (${PROFILE_COLUMNS})\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id) DO UPDATE SET\n\t\t percent = excluded.percent,\n\t\t weekly_hours = excluded.weekly_hours,\n\t\t workdays = excluded.workdays,\n\t\t start_date = excluded.start_date,\n\t\t end_date = excluded.end_date,\n\t\t overtime_carryover = excluded.overtime_carryover,\n\t\t vorholzeit_per_year = excluded.vorholzeit_per_year,\n\t\t vacation_carryover = excluded.vacation_carryover,\n\t\t vacation_per_year = excluded.vacation_per_year,\n\t\t overtime_model = excluded.overtime_model,\n\t\t holiday_flags = excluded.holiday_flags,\n\t\t pause_paid_minutes = excluded.pause_paid_minutes`,\n\t);\n\tconst selectRoles = db.prepare(\n\t\t`SELECT r.key FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? ORDER BY r.key`,\n\t);\n\tconst selectRoleId = db.prepare(\"SELECT id FROM roles WHERE key = ?\");\n\tconst deleteRoles = db.prepare(\"DELETE FROM user_roles WHERE user_id = ?\");\n\tconst insertRole = db.prepare(\"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)\");\n\tconst selectPermissions = db.prepare(\n\t\t`SELECT DISTINCT p.key FROM user_roles ur\n\t\t JOIN role_permissions rp ON rp.role_id = ur.role_id\n\t\t JOIN permissions p ON p.id = rp.permission_id\n\t\t WHERE ur.user_id = ? ORDER BY p.key`,\n\t);\n\tconst selectRoleCatalog = db.prepare(\n\t\t`SELECT r.key AS key, r.name AS name, p.key AS permission\n\t\t FROM roles r\n\t\t LEFT JOIN role_permissions rp ON rp.role_id = r.id\n\t\t LEFT JOIN permissions p ON p.id = rp.permission_id\n\t\t ORDER BY r.key, p.key`,\n\t);\n\n\tconst read = (id: number): UserRecord | null => {\n\t\tconst row = selectById.get(id) as UserRow | undefined;\n\t\treturn row ? mapUserRow(row) : null;\n\t};\n\n\tconst readProfile = (userId: number): WorkProfileRecord | null => {\n\t\tconst row = selectProfile.get(userId) as WorkProfileRow | undefined;\n\t\treturn row ? mapWorkProfileRow(row) : null;\n\t};\n\n\t/**\n\t * Defaults of a fresh work profile (identical to the column defaults of the schema).\n\t *\n\t * @param userId - owner of the profile\n\t */\n\tconst defaultProfile = (userId: number): WorkProfileRecord => ({\n\t\tuserId,\n\t\tpercent: 100,\n\t\tweeklyHours: 42.5,\n\t\tworkdays: \"0;1;1;1;1;1;0\",\n\t\tstartDate: null,\n\t\tendDate: null,\n\t\tovertimeCarryover: 0,\n\t\tvorholzeitPerYear: 0,\n\t\tvacationCarryover: 0,\n\t\tvacationPerYear: 0,\n\t\tovertimeModel: \"monthly\",\n\t\tholidayFlags: null,\n\t\tpausePaidMinutes: 0,\n\t});\n\n\tconst resolveRoleIds = (roleKeys: string[]): number[] =>\n\t\troleKeys.map(key => {\n\t\t\tconst row = selectRoleId.get(key) as { id: number } | undefined;\n\t\t\tif (!row) {\n\t\t\t\tthrow new UnknownRoleError(key);\n\t\t\t}\n\t\t\treturn row.id;\n\t\t});\n\n\tconst writeRoles = (userId: number, roleIds: number[]): void => {\n\t\tdeleteRoles.run(userId);\n\t\tfor (const roleId of roleIds) {\n\t\t\tinsertRole.run(userId, roleId);\n\t\t}\n\t};\n\n\treturn {\n\t\tcreate(input: CreateUserInput): UserRecord {\n\t\t\tconst login = input.login.trim();\n\t\t\tif (!login) {\n\t\t\t\tthrow new ValidationError(\"login must not be empty\");\n\t\t\t}\n\t\t\tconst existing = selectByLogin.get(login) as UserRow | undefined;\n\t\t\tif (existing) {\n\t\t\t\tthrow new LoginExistsError(login);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst roleKeys = input.roleKeys ?? [];\n\t\t\tconst roleIds = resolveRoleIds(roleKeys);\n\n\t\t\tlet userId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertUser.run(\n\t\t\t\t\tlogin,\n\t\t\t\t\tinput.passwordHash ?? \"\",\n\t\t\t\t\tinput.displayName,\n\t\t\t\t\tinput.email ?? null,\n\t\t\t\t\tinput.rfidCard ?? null,\n\t\t\t\t\t1,\n\t\t\t\t\tinput.mustChangePw === true ? 1 : 0,\n\t\t\t\t\tinput.locale ?? \"de-DE\",\n\t\t\t\t\tinput.timezone ?? \"Europe/Berlin\",\n\t\t\t\t\tnow,\n\t\t\t\t\tnow,\n\t\t\t\t);\n\t\t\t\tuserId = Number(result.lastInsertRowid);\n\t\t\t\tinsertDefaultProfile.run(userId);\n\t\t\t\twriteRoles(userId, roleIds);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId ?? null,\n\t\t\t\t\taction: \"user.create\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: userId,\n\t\t\t\t\tdetail: { login, displayName: input.displayName, roles: roleKeys.slice().sort() },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst created = read(userId);\n\t\t\tif (!created) {\n\t\t\t\tthrow new Error(`user ${userId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn created;\n\t\t},\n\n\t\tfindById: read,\n\n\t\tfindByLogin(login: string): UserRecord | null {\n\t\t\tconst row = selectByLogin.get(login.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tfindByRfidCard(card: string): UserRecord | null {\n\t\t\tconst row = selectByRfid.get(card.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tsetPin(input: {\n\t\t\tuserId: number;\n\t\t\tpinHash: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetPinHash.run(input.pinHash, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.pin\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the hash itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.pinHash === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tsetAvatar(input: {\n\t\t\tuserId: number;\n\t\t\tavatar: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetAvatarData.run(input.avatar, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.avatar\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the picture itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.avatar === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): UserRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as UserRow[];\n\t\t\treturn rows.map(mapUserRow);\n\t\t},\n\n\t\tupdate(input: UpdateUserInput): UserRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`user ${input.id} not found`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst next: UserRecord = {\n\t\t\t\t...current,\n\t\t\t\tdisplayName: input.patch.displayName ?? current.displayName,\n\t\t\t\temail: input.patch.email === undefined ? current.email : input.patch.email,\n\t\t\t\tlocale: input.patch.locale ?? current.locale,\n\t\t\t\ttimezone: input.patch.timezone ?? current.timezone,\n\t\t\t\trfidCard: input.patch.rfidCard === undefined ? current.rfidCard : input.patch.rfidCard,\n\t\t\t\tisActive: input.patch.isActive ?? current.isActive,\n\t\t\t\tmustChangePw: input.patch.mustChangePw ?? current.mustChangePw,\n\t\t\t\tpasswordHash: input.patch.passwordHash ?? current.passwordHash,\n\t\t\t};\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tcurrent as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_USER_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tconst passwordChanged = next.passwordHash !== current.passwordHash;\n\t\t\tif (Object.keys(changes).length === 0 && !passwordChanged) {\n\t\t\t\t// nothing to do, keep `updated_at` untouched\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst action =\n\t\t\t\t!current.isActive && next.isActive\n\t\t\t\t\t? \"user.activate\"\n\t\t\t\t\t: current.isActive && !next.isActive\n\t\t\t\t\t\t? \"user.deactivate\"\n\t\t\t\t\t\t: \"user.update\";\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupdateUser.run(\n\t\t\t\t\tnext.displayName,\n\t\t\t\t\tnext.email,\n\t\t\t\t\tnext.locale,\n\t\t\t\t\tnext.timezone,\n\t\t\t\t\tnext.rfidCard,\n\t\t\t\t\tnext.isActive ? 1 : 0,\n\t\t\t\t\tnext.mustChangePw ? 1 : 0,\n\t\t\t\t\tnext.passwordHash,\n\t\t\t\t\tnow,\n\t\t\t\t\tinput.id,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction,\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\t// never write the password hash into the audit trail\n\t\t\t\t\tdetail: { changes, passwordChanged, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst updated = read(input.id);\n\t\t\tif (!updated) {\n\t\t\t\tthrow new Error(`user ${input.id} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\troles(userId: number): string[] {\n\t\t\treturn (selectRoles.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\troleCatalog(): { key: string; name: string; permissions: string[] }[] {\n\t\t\tconst rows = selectRoleCatalog.all() as { key: string; name: string; permission: string | null }[];\n\t\t\tconst catalog = new Map<string, { key: string; name: string; permissions: string[] }>();\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst entry = catalog.get(row.key) ?? { key: row.key, name: row.name, permissions: [] };\n\t\t\t\tif (row.permission) {\n\t\t\t\t\tentry.permissions.push(row.permission);\n\t\t\t\t}\n\t\t\t\tcatalog.set(row.key, entry);\n\t\t\t}\n\t\t\treturn [...catalog.values()];\n\t\t},\n\n\t\tsetRoles(input: {\n\t\t\tuserId: number;\n\t\t\troleKeys: string[];\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): string[] {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\t// resolve (and validate) all keys before the first write\n\t\t\tconst roleIds = resolveRoleIds(input.roleKeys);\n\t\t\tconst before = this.roles(input.userId);\n\t\t\tconst after = input.roleKeys.slice().sort();\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\twriteRoles(input.userId, roleIds);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.roles\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { old: before, new: after },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn this.roles(input.userId);\n\t\t},\n\n\t\tpermissions(userId: number): string[] {\n\t\t\treturn (selectPermissions.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\thasPermissions(userId: number, required: string[]): boolean {\n\t\t\tconst granted = new Set(this.permissions(userId));\n\t\t\treturn required.every(permission => granted.has(permission));\n\t\t},\n\n\t\tgetWorkProfile: readProfile,\n\n\t\tsaveWorkProfile(input: {\n\t\t\tuserId: number;\n\t\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): WorkProfileRecord {\n\t\t\tconst current = readProfile(input.userId);\n\t\t\tconst base = current ?? defaultProfile(input.userId);\n\t\t\tconst next: WorkProfileRecord = { ...base, ...input.profile, userId: input.userId };\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tbase as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_PROFILE_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tif (current && Object.keys(changes).length === 0) {\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupsertProfile.run(\n\t\t\t\t\tnext.userId,\n\t\t\t\t\tnext.percent,\n\t\t\t\t\tnext.weeklyHours,\n\t\t\t\t\tnext.workdays,\n\t\t\t\t\tnext.startDate,\n\t\t\t\t\tnext.endDate,\n\t\t\t\t\tnext.overtimeCarryover,\n\t\t\t\t\tnext.vorholzeitPerYear,\n\t\t\t\t\tnext.vacationCarryover,\n\t\t\t\t\tnext.vacationPerYear,\n\t\t\t\t\tnext.overtimeModel,\n\t\t\t\t\tnext.holidayFlags,\n\t\t\t\t\tnext.pausePaidMinutes,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.profile\",\n\t\t\t\t\tentity: \"work_profile\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { created: current === null, changes },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = readProfile(input.userId);\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`work profile of user ${input.userId} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn saved;\n\t\t},\n\n\t\trecordLogin(userId: number, now?: number): void {\n\t\t\tupdateLastLogin.run(now ?? Math.floor(Date.now() / 1000), userId);\n\t\t},\n\t};\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUA,oBAA+C;AAC/C,mBAA0C;
|
|
4
|
+
"sourcesContent": ["/**\n * User repository (`users`, `user_roles`, `work_profiles`).\n *\n * Every write records an audit entry in the same transaction; passwords and password hashes never end up\n * in the audit trail, only the fact that they were changed.\n */\n\nimport type { Db } from \"../database\";\nimport type { OvertimeModel } from \"../../domain/calculation\";\nimport type { WorkProfile } from \"../../domain/target\";\nimport { NotFoundError, ValidationError } from \"../../errors\";\nimport { writeAuditLog, diffFields } from \"./audit\";\n\n/** A user as stored in the database. */\nexport interface UserRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Unique login name */\n\tlogin: string;\n\t/** Hash of the password (never leaves the server) */\n\tpasswordHash: string;\n\t/** Name shown in the UI */\n\tdisplayName: string;\n\t/** E-mail address */\n\temail: string | null;\n\t/** RFID card id */\n\trfidCard: string | null;\n\t/** False for deactivated accounts */\n\tisActive: boolean;\n\t/** True while the user has to set a new password */\n\tmustChangePw: boolean;\n\t/** Preferred language */\n\tlocale: string;\n\t/** IANA time zone of the user */\n\ttimezone: string;\n\t/** Hash of the personal kiosk PIN, `null` when none is set */\n\tpinHash: string | null;\n\t/** Picture of the employee as a data URL, `null` when none is stored */\n\tavatar: string | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n\t/** Instant of the last change, UTC epoch seconds */\n\tupdatedAt: number;\n\t/** Instant of the last successful login, `null` if never logged in */\n\tlastLoginAt: number | null;\n}\n\n/** Employment parameters of a user (`work_profiles`). */\nexport interface WorkProfileRecord extends WorkProfile {\n\t/** Owner of the profile */\n\tuserId: number;\n\t/** Employment level in percent */\n\tpercent: number;\n\t/** Contracted hours per week at 100 % */\n\tweeklyHours: number;\n\t/** Active working days, `0;1;\u2026` for Sunday\u2026Saturday */\n\tworkdays: string;\n\t/** First day of the employment (UTC epoch seconds) */\n\tstartDate: number | null;\n\t/** Last day of the employment (UTC epoch seconds) */\n\tendDate: number | null;\n\t/** Overtime taken over from the previous year, in minutes */\n\tovertimeCarryover: number;\n\t/** Annual \u201CVorholzeit\u201D in minutes */\n\tvorholzeitPerYear: number;\n\t/** Vacation days taken over from the previous year */\n\tvacationCarryover: number;\n\t/** Annual vacation entitlement in days */\n\tvacationPerYear: number;\n\t/** Overtime model used for the reports */\n\tovertimeModel: OvertimeModel;\n\t/** Country specific holiday flags (JSON), `null` = instance default */\n\tholidayFlags: string | null;\n\t/** Minutes of the break per day that are paid (0 = the break is not paid at all) */\n\tpausePaidMinutes: number;\n\t/** True when the presence card shows the working time of today for this employee */\n\tshowWorkedTime: boolean;\n}\n\n/** Input for creating a user. */\nexport interface CreateUserInput {\n\t/** Unique login name */\n\tlogin: string;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** Password hash (already hashed by the caller) */\n\tpasswordHash?: string;\n\t/** E-mail address */\n\temail?: string | null;\n\t/** Preferred language, default `de-DE` */\n\tlocale?: string;\n\t/** IANA time zone, default `Europe/Berlin` */\n\ttimezone?: string;\n\t/** RFID card id */\n\trfidCard?: string | null;\n\t/** Forces a password change on the next login */\n\tmustChangePw?: boolean;\n\t/** Roles to assign (all permissions are granted by the seed roles) */\n\troleKeys?: string[];\n\t/** Id of the user who is an additional employee of the new account (self service) */\n\tactorId?: number | null;\n\t/** Client IP address */\n\tactorIp?: string | null;\n\t/** Instant of creation, defaults to now */\n\tnow?: number;\n}\n\n/** Input for changing a user. */\nexport interface UpdateUserInput {\n\t/** Id of the changed user */\n\tid: number;\n\t/** Fields to change */\n\tpatch: {\n\t\tdisplayName?: string;\n\t\temail?: string | null;\n\t\tlocale?: string;\n\t\ttimezone?: string;\n\t\trfidCard?: string | null;\n\t\tisActive?: boolean;\n\t\tmustChangePw?: boolean;\n\t\t/** Password hash (already hashed by the caller) */\n\t\tpasswordHash?: string;\n\t};\n\t/** Reason of the change (recorded in the audit trail) */\n\treason?: string | null;\n\t/** Who performs the change */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Instant of the change, defaults to now */\n\tnow?: number;\n}\n\n/** Thrown when a login name is already taken. */\nexport class LoginExistsError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param login - login name that is already taken\n\t */\n\tconstructor(public readonly login: string) {\n\t\tsuper(`login \"${login}\" already exists`);\n\t\tthis.name = \"LoginExistsError\";\n\t}\n}\n\n/** Thrown when a role key is not known. */\nexport class UnknownRoleError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param roleKey - unknown role key\n\t */\n\tconstructor(public readonly roleKey: string) {\n\t\tsuper(`unknown role \"${roleKey}\"`);\n\t\tthis.name = \"UnknownRoleError\";\n\t}\n}\n\n/** User storage operations. */\nexport interface UsersRepository {\n\t/** Creates a user including a default work profile */\n\tcreate(input: CreateUserInput): UserRecord;\n\t/** Reads a user by id */\n\tfindById(id: number): UserRecord | null;\n\t/** Reads a user by login (case insensitive) */\n\tfindByLogin(login: string): UserRecord | null;\n\t/** Reads a user by the RFID card of the badge terminal */\n\tfindByRfidCard(card: string): UserRecord | null;\n\t/** Stores or clears the personal kiosk PIN (never written to the audit trail) */\n\tsetPin(input: {\n\t\tuserId: number;\n\t\tpinHash: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): void;\n\t/** Stores or clears the picture of an employee (the image itself never reaches the audit trail) */\n\tsetAvatar(input: {\n\t\t/** Id of the employee */\n\t\tuserId: number;\n\t\t/** Data URL of the picture, `null` clears it */\n\t\tavatar: string | null;\n\t\t/** Who changes the picture */\n\t\tactorId: number;\n\t\t/** Client IP address of the actor */\n\t\tactorIp?: string | null;\n\t\t/** Instant of the change, defaults to now */\n\t\tnow?: number;\n\t}): void;\n\t/** All users, optionally including the deactivated ones */\n\tlist(options?: { includeInactive?: boolean }): UserRecord[];\n\t/** Changes a user and audits the changed fields */\n\tupdate(input: UpdateUserInput): UserRecord;\n\t/** Role keys of a user (sorted) */\n\troles(userId: number): string[];\n\t/** All roles with their permission keys, sorted by key */\n\troleCatalog(): { key: string; name: string; permissions: string[] }[];\n\t/** Replaces the roles of a user */\n\tsetRoles(input: {\n\t\tuserId: number;\n\t\troleKeys: string[];\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): string[];\n\t/** Effective permission keys of a user (sorted) */\n\tpermissions(userId: number): string[];\n\t/** Checks whether a user holds all given permissions */\n\thasPermissions(userId: number, required: string[]): boolean;\n\t/** Work profile of a user, `null` when none is stored */\n\tgetWorkProfile(userId: number): WorkProfileRecord | null;\n\t/** Creates or updates the work profile */\n\tsaveWorkProfile(input: {\n\t\tuserId: number;\n\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): WorkProfileRecord;\n\t/** Stores the instant of the last successful login */\n\trecordLogin(userId: number, now?: number): void;\n}\n\ninterface UserRow {\n\tid: number;\n\tlogin: string;\n\tpassword_hash: string;\n\tdisplay_name: string;\n\temail: string | null;\n\trfid_card: string | null;\n\tis_active: number;\n\tmust_change_pw: number;\n\tlocale: string;\n\ttimezone: string;\n\tpin_hash: string | null;\n\tavatar: string | null;\n\tcreated_at: number;\n\tupdated_at: number;\n\tlast_login_at: number | null;\n}\n\ninterface WorkProfileRow {\n\tuser_id: number;\n\tpercent: number;\n\tweekly_hours: number;\n\tworkdays: string;\n\tstart_date: number | null;\n\tend_date: number | null;\n\tovertime_carryover: number;\n\tvorholzeit_per_year: number;\n\tvacation_carryover: number;\n\tvacation_per_year: number;\n\tovertime_model: OvertimeModel;\n\tholiday_flags: string | null;\n\tpause_paid_minutes: number;\n\tshow_worked_time: number;\n}\n\n/**\n * Maps a database row to a user record.\n *\n * @param row - raw database row\n * @returns user record\n */\nexport function mapUserRow(row: UserRow): UserRecord {\n\treturn {\n\t\tid: row.id,\n\t\tlogin: row.login,\n\t\tpasswordHash: row.password_hash,\n\t\tdisplayName: row.display_name,\n\t\temail: row.email,\n\t\trfidCard: row.rfid_card,\n\t\tisActive: row.is_active !== 0,\n\t\tmustChangePw: row.must_change_pw !== 0,\n\t\tlocale: row.locale,\n\t\ttimezone: row.timezone,\n\t\tpinHash: row.pin_hash,\n\t\tavatar: row.avatar,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t\tlastLoginAt: row.last_login_at,\n\t};\n}\n\n/**\n * Maps a database row to a work profile record.\n *\n * @param row - raw database row\n * @returns work profile record\n */\nexport function mapWorkProfileRow(row: WorkProfileRow): WorkProfileRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tpercent: row.percent,\n\t\tweeklyHours: row.weekly_hours,\n\t\tworkdays: row.workdays,\n\t\tstartDate: row.start_date,\n\t\tendDate: row.end_date,\n\t\tovertimeCarryover: row.overtime_carryover,\n\t\tvorholzeitPerYear: row.vorholzeit_per_year,\n\t\tvacationCarryover: row.vacation_carryover,\n\t\tvacationPerYear: row.vacation_per_year,\n\t\tovertimeModel: row.overtime_model,\n\t\tholidayFlags: row.holiday_flags,\n\t\tpausePaidMinutes: row.pause_paid_minutes,\n\t\tshowWorkedTime: row.show_worked_time !== 0,\n\t};\n}\n\nconst USER_COLUMNS = `id, login, password_hash, display_name, email, rfid_card, is_active,\n\\tmust_change_pw, locale, timezone, pin_hash, avatar, created_at, updated_at, last_login_at`;\n\nconst PROFILE_COLUMNS = `user_id, percent, weekly_hours, workdays, start_date, end_date, overtime_carryover,\n\\tvorholzeit_per_year, vacation_carryover, vacation_per_year, overtime_model, holiday_flags, pause_paid_minutes,\n\tshow_worked_time`;\n\n/** Field names of a user that are compared for the audit trail (the password is reported separately). */\nconst AUDITED_USER_FIELDS: (keyof UserRecord)[] = [\n\t\"login\",\n\t\"displayName\",\n\t\"email\",\n\t\"rfidCard\",\n\t\"isActive\",\n\t\"mustChangePw\",\n\t\"locale\",\n\t\"timezone\",\n];\n\n/** Field names of a work profile that are compared for the audit trail. */\n/** Field names of a work profile that are compared for the audit trail. */\nconst AUDITED_PROFILE_FIELDS: (keyof WorkProfileRecord)[] = [\n\t\"percent\",\n\t\"weeklyHours\",\n\t\"workdays\",\n\t\"startDate\",\n\t\"endDate\",\n\t\"overtimeCarryover\",\n\t\"vorholzeitPerYear\",\n\t\"vacationCarryover\",\n\t\"vacationPerYear\",\n\t\"overtimeModel\",\n\t\"holidayFlags\",\n\t\"pausePaidMinutes\",\n\t\"showWorkedTime\",\n];\n\n/**\n * Creates the user repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createUsersRepository(db: Db): UsersRepository {\n\tconst selectById = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE id = ?`);\n\tconst selectByLogin = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE login = ? COLLATE NOCASE`);\n\tconst selectByRfid = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE rfid_card = ? COLLATE NOCASE LIMIT 1`);\n\tconst setPinHash = db.prepare(\"UPDATE users SET pin_hash = ?, updated_at = ? WHERE id = ?\");\n\tconst setAvatarData = db.prepare(\"UPDATE users SET avatar = ?, updated_at = ? WHERE id = ?\");\n\tconst selectAll = db.prepare(`SELECT ${USER_COLUMNS} FROM users ORDER BY display_name COLLATE NOCASE, id`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${USER_COLUMNS} FROM users WHERE is_active = 1 ORDER BY display_name COLLATE NOCASE, id`,\n\t);\n\tconst insertUser = db.prepare(\n\t\t`INSERT INTO users\n\t\t (login, password_hash, display_name, email, rfid_card, is_active, must_change_pw,\n\t\t locale, timezone, created_at, updated_at)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateUser = db.prepare(\n\t\t`UPDATE users SET display_name = ?, email = ?, locale = ?, timezone = ?, rfid_card = ?,\n\t\t is_active = ?, must_change_pw = ?, password_hash = ?, updated_at = ?\n\t\t WHERE id = ?`,\n\t);\n\tconst updateLastLogin = db.prepare(\"UPDATE users SET last_login_at = ? WHERE id = ?\");\n\tconst insertDefaultProfile = db.prepare(\"INSERT OR IGNORE INTO work_profiles (user_id) VALUES (?)\");\n\tconst selectProfile = db.prepare(`SELECT ${PROFILE_COLUMNS} FROM work_profiles WHERE user_id = ?`);\n\tconst upsertProfile = db.prepare(\n\t\t`INSERT INTO work_profiles (${PROFILE_COLUMNS})\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id) DO UPDATE SET\n\t\t percent = excluded.percent,\n\t\t weekly_hours = excluded.weekly_hours,\n\t\t workdays = excluded.workdays,\n\t\t start_date = excluded.start_date,\n\t\t end_date = excluded.end_date,\n\t\t overtime_carryover = excluded.overtime_carryover,\n\t\t vorholzeit_per_year = excluded.vorholzeit_per_year,\n\t\t vacation_carryover = excluded.vacation_carryover,\n\t\t vacation_per_year = excluded.vacation_per_year,\n\t\t overtime_model = excluded.overtime_model,\n\t\t holiday_flags = excluded.holiday_flags,\n\t\t pause_paid_minutes = excluded.pause_paid_minutes,\n\t\t show_worked_time = excluded.show_worked_time`,\n\t);\n\tconst selectRoles = db.prepare(\n\t\t`SELECT r.key FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? ORDER BY r.key`,\n\t);\n\tconst selectRoleId = db.prepare(\"SELECT id FROM roles WHERE key = ?\");\n\tconst deleteRoles = db.prepare(\"DELETE FROM user_roles WHERE user_id = ?\");\n\tconst insertRole = db.prepare(\"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)\");\n\tconst selectPermissions = db.prepare(\n\t\t`SELECT DISTINCT p.key FROM user_roles ur\n\t\t JOIN role_permissions rp ON rp.role_id = ur.role_id\n\t\t JOIN permissions p ON p.id = rp.permission_id\n\t\t WHERE ur.user_id = ? ORDER BY p.key`,\n\t);\n\tconst selectRoleCatalog = db.prepare(\n\t\t`SELECT r.key AS key, r.name AS name, p.key AS permission\n\t\t FROM roles r\n\t\t LEFT JOIN role_permissions rp ON rp.role_id = r.id\n\t\t LEFT JOIN permissions p ON p.id = rp.permission_id\n\t\t ORDER BY r.key, p.key`,\n\t);\n\n\tconst read = (id: number): UserRecord | null => {\n\t\tconst row = selectById.get(id) as UserRow | undefined;\n\t\treturn row ? mapUserRow(row) : null;\n\t};\n\n\tconst readProfile = (userId: number): WorkProfileRecord | null => {\n\t\tconst row = selectProfile.get(userId) as WorkProfileRow | undefined;\n\t\treturn row ? mapWorkProfileRow(row) : null;\n\t};\n\n\t/**\n\t * Defaults of a fresh work profile (identical to the column defaults of the schema).\n\t *\n\t * @param userId - owner of the profile\n\t */\n\tconst defaultProfile = (userId: number): WorkProfileRecord => ({\n\t\tuserId,\n\t\tpercent: 100,\n\t\tweeklyHours: 42.5,\n\t\tworkdays: \"0;1;1;1;1;1;0\",\n\t\tstartDate: null,\n\t\tendDate: null,\n\t\tovertimeCarryover: 0,\n\t\tvorholzeitPerYear: 0,\n\t\tvacationCarryover: 0,\n\t\tvacationPerYear: 0,\n\t\tovertimeModel: \"monthly\",\n\t\tholidayFlags: null,\n\t\tpausePaidMinutes: 0,\n\t\tshowWorkedTime: false,\n\t});\n\n\tconst resolveRoleIds = (roleKeys: string[]): number[] =>\n\t\troleKeys.map(key => {\n\t\t\tconst row = selectRoleId.get(key) as { id: number } | undefined;\n\t\t\tif (!row) {\n\t\t\t\tthrow new UnknownRoleError(key);\n\t\t\t}\n\t\t\treturn row.id;\n\t\t});\n\n\tconst writeRoles = (userId: number, roleIds: number[]): void => {\n\t\tdeleteRoles.run(userId);\n\t\tfor (const roleId of roleIds) {\n\t\t\tinsertRole.run(userId, roleId);\n\t\t}\n\t};\n\n\treturn {\n\t\tcreate(input: CreateUserInput): UserRecord {\n\t\t\tconst login = input.login.trim();\n\t\t\tif (!login) {\n\t\t\t\tthrow new ValidationError(\"login must not be empty\");\n\t\t\t}\n\t\t\tconst existing = selectByLogin.get(login) as UserRow | undefined;\n\t\t\tif (existing) {\n\t\t\t\tthrow new LoginExistsError(login);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst roleKeys = input.roleKeys ?? [];\n\t\t\tconst roleIds = resolveRoleIds(roleKeys);\n\n\t\t\tlet userId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertUser.run(\n\t\t\t\t\tlogin,\n\t\t\t\t\tinput.passwordHash ?? \"\",\n\t\t\t\t\tinput.displayName,\n\t\t\t\t\tinput.email ?? null,\n\t\t\t\t\tinput.rfidCard ?? null,\n\t\t\t\t\t1,\n\t\t\t\t\tinput.mustChangePw === true ? 1 : 0,\n\t\t\t\t\tinput.locale ?? \"de-DE\",\n\t\t\t\t\tinput.timezone ?? \"Europe/Berlin\",\n\t\t\t\t\tnow,\n\t\t\t\t\tnow,\n\t\t\t\t);\n\t\t\t\tuserId = Number(result.lastInsertRowid);\n\t\t\t\tinsertDefaultProfile.run(userId);\n\t\t\t\twriteRoles(userId, roleIds);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId ?? null,\n\t\t\t\t\taction: \"user.create\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: userId,\n\t\t\t\t\tdetail: { login, displayName: input.displayName, roles: roleKeys.slice().sort() },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst created = read(userId);\n\t\t\tif (!created) {\n\t\t\t\tthrow new Error(`user ${userId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn created;\n\t\t},\n\n\t\tfindById: read,\n\n\t\tfindByLogin(login: string): UserRecord | null {\n\t\t\tconst row = selectByLogin.get(login.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tfindByRfidCard(card: string): UserRecord | null {\n\t\t\tconst row = selectByRfid.get(card.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tsetPin(input: {\n\t\t\tuserId: number;\n\t\t\tpinHash: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetPinHash.run(input.pinHash, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.pin\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the hash itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.pinHash === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tsetAvatar(input: {\n\t\t\tuserId: number;\n\t\t\tavatar: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetAvatarData.run(input.avatar, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.avatar\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the picture itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.avatar === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): UserRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as UserRow[];\n\t\t\treturn rows.map(mapUserRow);\n\t\t},\n\n\t\tupdate(input: UpdateUserInput): UserRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`user ${input.id} not found`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst next: UserRecord = {\n\t\t\t\t...current,\n\t\t\t\tdisplayName: input.patch.displayName ?? current.displayName,\n\t\t\t\temail: input.patch.email === undefined ? current.email : input.patch.email,\n\t\t\t\tlocale: input.patch.locale ?? current.locale,\n\t\t\t\ttimezone: input.patch.timezone ?? current.timezone,\n\t\t\t\trfidCard: input.patch.rfidCard === undefined ? current.rfidCard : input.patch.rfidCard,\n\t\t\t\tisActive: input.patch.isActive ?? current.isActive,\n\t\t\t\tmustChangePw: input.patch.mustChangePw ?? current.mustChangePw,\n\t\t\t\tpasswordHash: input.patch.passwordHash ?? current.passwordHash,\n\t\t\t};\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tcurrent as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_USER_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tconst passwordChanged = next.passwordHash !== current.passwordHash;\n\t\t\tif (Object.keys(changes).length === 0 && !passwordChanged) {\n\t\t\t\t// nothing to do, keep `updated_at` untouched\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst action =\n\t\t\t\t!current.isActive && next.isActive\n\t\t\t\t\t? \"user.activate\"\n\t\t\t\t\t: current.isActive && !next.isActive\n\t\t\t\t\t\t? \"user.deactivate\"\n\t\t\t\t\t\t: \"user.update\";\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupdateUser.run(\n\t\t\t\t\tnext.displayName,\n\t\t\t\t\tnext.email,\n\t\t\t\t\tnext.locale,\n\t\t\t\t\tnext.timezone,\n\t\t\t\t\tnext.rfidCard,\n\t\t\t\t\tnext.isActive ? 1 : 0,\n\t\t\t\t\tnext.mustChangePw ? 1 : 0,\n\t\t\t\t\tnext.passwordHash,\n\t\t\t\t\tnow,\n\t\t\t\t\tinput.id,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction,\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\t// never write the password hash into the audit trail\n\t\t\t\t\tdetail: { changes, passwordChanged, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst updated = read(input.id);\n\t\t\tif (!updated) {\n\t\t\t\tthrow new Error(`user ${input.id} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\troles(userId: number): string[] {\n\t\t\treturn (selectRoles.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\troleCatalog(): { key: string; name: string; permissions: string[] }[] {\n\t\t\tconst rows = selectRoleCatalog.all() as { key: string; name: string; permission: string | null }[];\n\t\t\tconst catalog = new Map<string, { key: string; name: string; permissions: string[] }>();\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst entry = catalog.get(row.key) ?? { key: row.key, name: row.name, permissions: [] };\n\t\t\t\tif (row.permission) {\n\t\t\t\t\tentry.permissions.push(row.permission);\n\t\t\t\t}\n\t\t\t\tcatalog.set(row.key, entry);\n\t\t\t}\n\t\t\treturn [...catalog.values()];\n\t\t},\n\n\t\tsetRoles(input: {\n\t\t\tuserId: number;\n\t\t\troleKeys: string[];\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): string[] {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\t// resolve (and validate) all keys before the first write\n\t\t\tconst roleIds = resolveRoleIds(input.roleKeys);\n\t\t\tconst before = this.roles(input.userId);\n\t\t\tconst after = input.roleKeys.slice().sort();\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\twriteRoles(input.userId, roleIds);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.roles\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { old: before, new: after },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn this.roles(input.userId);\n\t\t},\n\n\t\tpermissions(userId: number): string[] {\n\t\t\treturn (selectPermissions.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\thasPermissions(userId: number, required: string[]): boolean {\n\t\t\tconst granted = new Set(this.permissions(userId));\n\t\t\treturn required.every(permission => granted.has(permission));\n\t\t},\n\n\t\tgetWorkProfile: readProfile,\n\n\t\tsaveWorkProfile(input: {\n\t\t\tuserId: number;\n\t\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): WorkProfileRecord {\n\t\t\tconst current = readProfile(input.userId);\n\t\t\tconst base = current ?? defaultProfile(input.userId);\n\t\t\tconst next: WorkProfileRecord = { ...base, ...input.profile, userId: input.userId };\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tbase as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_PROFILE_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tif (current && Object.keys(changes).length === 0) {\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupsertProfile.run(\n\t\t\t\t\tnext.userId,\n\t\t\t\t\tnext.percent,\n\t\t\t\t\tnext.weeklyHours,\n\t\t\t\t\tnext.workdays,\n\t\t\t\t\tnext.startDate,\n\t\t\t\t\tnext.endDate,\n\t\t\t\t\tnext.overtimeCarryover,\n\t\t\t\t\tnext.vorholzeitPerYear,\n\t\t\t\t\tnext.vacationCarryover,\n\t\t\t\t\tnext.vacationPerYear,\n\t\t\t\t\tnext.overtimeModel,\n\t\t\t\t\tnext.holidayFlags,\n\t\t\t\t\tnext.pausePaidMinutes,\n\t\t\t\t\tnext.showWorkedTime ? 1 : 0,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.profile\",\n\t\t\t\t\tentity: \"work_profile\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { created: current === null, changes },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = readProfile(input.userId);\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`work profile of user ${input.userId} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn saved;\n\t\t},\n\n\t\trecordLogin(userId: number, now?: number): void {\n\t\t\tupdateLastLogin.run(now ?? Math.floor(Date.now() / 1000), userId);\n\t\t},\n\t};\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["_a", "_b"]
|
|
7
7
|
}
|