iobroker.time-tracker 0.7.5 → 0.7.6

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 CHANGED
@@ -306,10 +306,13 @@ The adapter can act on its own as well — that table lives in **Administration
306
306
  | Clock out automatically | At the configured local time of the employee the open day is closed with a punch (note `auto.clockOut`) |
307
307
  | Report a missing punch | The same moment, but nothing is written — the instance only reports it |
308
308
  | Break reminder | Reminds an employee whose running work block reached the configured length |
309
+ | Validity period | Optional “valid from” and “valid until” as dates: the rule stays quiet outside them, both ends belong to the period, an empty field means “from now on” or “without an end” |
309
310
 
310
311
  Every rule runs **at most once per employee and local date**; `automation_runs` holds that decision and doubles as
311
- the log shown below the table. The events `automation.clockOut`, `automation.missingPunch` and
312
- `automation.breakReminder` appear in `events.*` too, so a notification adapter can pick them up.
312
+ the log below the table (the five most recent runs). A **validity period** is compared with the local calendar day of
313
+ the employee, like the weekdays, so a holiday stand-in can end by itself. The events `automation.clockOut`,
314
+ `automation.missingPunch` and `automation.breakReminder` appear in `events.*` too, so a notification adapter can pick
315
+ them up.
313
316
 
314
317
  ### Messages (`sendTo`)
315
318
 
@@ -412,6 +415,14 @@ local SQLite file, access is role-based, and every correction is written to an a
412
415
 
413
416
  ### **WORK IN PROGRESS**
414
417
 
418
+ ### 0.7.6 (2026-09-25)
419
+
420
+ - (Alex) new: an automatic rule can be limited to a period. The rule dialog knows “Valid from” and “Valid until” as
421
+ dates — an empty field means “from now on” or “without an end” — and the adapter compares them with the local
422
+ calendar day of the employee, exactly like the weekdays; a holiday stand-in, a seasonal worker or a project phase
423
+ therefore ends by itself
424
+ - (Alex) change: the list of the last runs below the rules shows the five most recent entries instead of twenty
425
+
415
426
  ### 0.7.5 (2026-09-24)
416
427
 
417
428
  - (Alex) change: the sources of the web app moved from `src-pwa/` to `src-www/`. The repository checker reads every folder it does not know by name and had reported five `W5042` for react, MUI and i18next - packages only the web app needs and that an adapter installation should not carry; the new name is on its list, so the warnings are gone. Nothing changes for a running installation: the built app is still served from `www/`
@@ -453,25 +464,6 @@ local SQLite file, access is role-based, and every correction is written to an a
453
464
  - (Alex) fix: the list of the absence types shows “visible for everybody” on a line of its own, so the long facts
454
465
  (`reduces vacation`) cannot push it out of the row any more
455
466
 
456
- ### 0.7.1 (2026-09-24)
457
-
458
- - (Alex) fix: the correction dialog in the month view of an employee shows and changes **his** punches. It asked the
459
- API without the employee, so the administration saw, moved and added punches of their own account — “add a punch”
460
- even created one there
461
- - (Alex) change: times belong to the administration. An employee no longer changes own times (`POST /entries` and
462
- every change of a time now need `time.edit_other`); instead he leaves a **note for the day** (“forgot to clock
463
- out”), which the administration reads in the month view and marks as handled (new table `day_notes`, migration 25).
464
- A queued punch of the offline queue that is older than the edit window arrives as the conflict `too_old`
465
- - (Alex) change: the **administrator** account belongs to nobody and does not punch any more — the role loses
466
- `time.punch` (migration 26), and the dashboard explains what the account is for. A manager punches and corrects as
467
- before; whoever also works gets the right back through the `employee` role
468
- - (Alex) fix: `GET /entries/conflicts` accepts `?userId=`, so the administration sees the conflict queue of an
469
- employee
470
- - (Alex) fix: the list of the absence types writes “visible for everybody” at the types the employees may really
471
- pick — it was written at the switched off ones and at nothing else
472
- - (Alex) fix: on a narrow phone the balance of a day (month) and of a month (statements) no longer runs over the
473
- text — the pencil, the balance and the download buttons stand beside the text instead of being laid over it
474
-
475
467
  Older entries are kept in [`CHANGELOG_OLD.md`](CHANGELOG_OLD.md).
476
468
 
477
469
  ## Provenance
@@ -36,6 +36,15 @@ function evaluateAutomation(rule, context) {
36
36
  if (!rule.weekdays.includes(context.weekday)) {
37
37
  return { fire: false, reason: `the rule does not run on weekday ${context.weekday}` };
38
38
  }
39
+ if (rule.activeFrom && context.localDate < rule.activeFrom) {
40
+ return {
41
+ fire: false,
42
+ reason: `the rule is only valid from ${rule.activeFrom} (today is ${context.localDate})`
43
+ };
44
+ }
45
+ if (rule.activeUntil && context.localDate > rule.activeUntil) {
46
+ return { fire: false, reason: `the rule ended on ${rule.activeUntil} (today is ${context.localDate})` };
47
+ }
39
48
  if (rule.kind === "breakReminder") {
40
49
  if (!context.hasOpenEntry) {
41
50
  return { fire: false, reason: "the employee is not clocked in" };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/adapter/automation.ts"],
4
- "sourcesContent": ["/**\n * Automation rules of the adapter.\n *\n * The adapter checks every minute whether one of the configured rules is due. This module only *decides* \u2014 the\n * adapter loads the punches and runs the decision \u2014 so the rules stay testable without ioBroker and without a\n * clock, exactly like the trigger rules.\n *\n * The three kinds:\n * - `clockOut`: at the configured local time an employee who is still clocked in is punched out.\n * - `missingPunch`: the same moment, but only reported \u2014 nothing is written.\n * - `breakReminder`: an employee whose running work block reached the configured length is reminded.\n *\n * Every rule runs at most once per employee and period \u2014 a local date, or an ISO week for a weekly rule; the adapter\n * asks `automation_runs` before it acts. A rule can also be limited to certain weekdays.\n */\n\nimport type { AutomationRepeat, AutomationRuleRecord } from \"../db/repositories/automations\";\n\n/** What the adapter knows about one employee at the moment of a check. */\nexport interface AutomationContext {\n\t/** Local date of the employee, e.g. `2026-09-18` */\n\tlocalDate: string;\n\t/** Weekday of that date, ISO counting (1 = Monday \u2026 7 = Sunday) */\n\tweekday: number;\n\t/** Minutes since local midnight */\n\tminuteOfDay: number;\n\t/** True when the employee is clocked in */\n\thasOpenEntry: boolean;\n\t/** Minutes of the running work block, `null` when the employee is not clocked in */\n\tblockMinutes: number | null;\n\t/** True when the rule already ran for this employee today */\n\talreadyRan: boolean;\n}\n\n/** Outcome of a check. */\nexport interface AutomationDecision {\n\t/** True when the rule is due */\n\tfire: boolean;\n\t/** Why it fires or not \u2014 goes to the log and to the tests */\n\treason: string;\n}\n\n/**\n * Decides whether a rule is due for one employee.\n *\n * @param rule - rule to check\n * @param context - what the adapter knows about the employee\n * @returns the decision, including the reason for the log\n */\nexport function evaluateAutomation(rule: AutomationRuleRecord, context: AutomationContext): AutomationDecision {\n\tif (!rule.isActive) {\n\t\treturn { fire: false, reason: \"the rule is disabled\" };\n\t}\n\tif (context.alreadyRan) {\n\t\treturn { fire: false, reason: \"it already ran for this employee today\" };\n\t}\n\t// the weekday is checked before the kind: a rule for Monday to Friday stays quiet at the weekend\n\tif (!rule.weekdays.includes(context.weekday)) {\n\t\treturn { fire: false, reason: `the rule does not run on weekday ${context.weekday}` };\n\t}\n\n\tif (rule.kind === \"breakReminder\") {\n\t\tif (!context.hasOpenEntry) {\n\t\t\treturn { fire: false, reason: \"the employee is not clocked in\" };\n\t\t}\n\t\tconst length = context.blockMinutes ?? 0;\n\t\tconst wanted = rule.afterMinutes ?? 0;\n\t\treturn length >= wanted\n\t\t\t? { fire: true, reason: `working for ${length} minutes without a break` }\n\t\t\t: { fire: false, reason: `working for ${length} of ${wanted} minutes` };\n\t}\n\n\tconst wanted = rule.atMinute ?? 0;\n\tif (context.minuteOfDay < wanted) {\n\t\treturn { fire: false, reason: `waiting for minute ${wanted} of the day` };\n\t}\n\t// the mirror image of the clock out: somebody who is still missing gets a punch in\n\tif (rule.kind === \"clockIn\") {\n\t\treturn context.hasOpenEntry\n\t\t\t? { fire: false, reason: \"the employee is already clocked in\" }\n\t\t\t: { fire: true, reason: \"not clocked in yet, punching in\" };\n\t}\n\tif (!context.hasOpenEntry) {\n\t\treturn { fire: false, reason: \"the employee is not clocked in\" };\n\t}\n\treturn {\n\t\tfire: true,\n\t\treason: rule.kind === \"clockOut\" ? \"still clocked in, punching out\" : \"still clocked in\",\n\t};\n}\n\n/**\n * Weekday of a local date in ISO counting.\n *\n * @param localDate - date as `YYYY-MM-DD`\n * @returns 1 for Monday \u2026 7 for Sunday\n */\nexport function isoWeekday(localDate: string): number {\n\tconst [year, month, day] = localDate.split(\"-\").map(Number);\n\tconst weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();\n\treturn weekday === 0 ? 7 : weekday;\n}\n\n/**\n * ISO week of a local date, e.g. `2026-W38`.\n *\n * The week belongs to the year of its Thursday, which is what makes the turn of the year come out right.\n *\n * @param localDate - date as `YYYY-MM-DD`\n * @returns year and week\n */\nexport function isoWeek(localDate: string): string {\n\tconst [year, month, day] = localDate.split(\"-\").map(Number);\n\tconst date = new Date(Date.UTC(year, month - 1, day));\n\tconst thursday = new Date(date);\n\tthursday.setUTCDate(date.getUTCDate() + (4 - isoWeekday(localDate)));\n\t// the 4th of January is always in the first ISO week, so its Thursday is the reference point\n\tconst januaryFourth = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));\n\tconst firstThursday = new Date(januaryFourth);\n\tfirstThursday.setUTCDate(januaryFourth.getUTCDate() + (4 - isoWeekday(januaryFourth.toISOString().slice(0, 10))));\n\tconst week = 1 + Math.round((thursday.getTime() - firstThursday.getTime()) / (7 * 86400000));\n\treturn `${thursday.getUTCFullYear()}-W${String(week).padStart(2, \"0\")}`;\n}\n\n/**\n * Period a rule counts in.\n *\n * @param repeat - whether the rule may act once a day or once a week\n * @param localDate - date as `YYYY-MM-DD`\n * @returns the local date, or the ISO week for a weekly rule\n */\nexport function runPeriod(repeat: AutomationRepeat, localDate: string): string {\n\treturn repeat === \"week\" ? isoWeek(localDate) : localDate;\n}\n\n/**\n * Reads the running work block of an employee from the punches of the day.\n *\n * The direction hints of the punches are used, which is what the day view shows as well; the authoritative pairing\n * of a day (and therefore the punch the adapter writes) stays with `domain/punch`. A reminder is advisory, so the\n * hint is good enough here \u2014 and an employee who is not clocked in has no block at all.\n *\n * @param punches - punches of the local day, any order\n * @param nowUtc - instant of the check, UTC epoch seconds\n * @returns true when the employee is clocked in, plus the minutes of the running block\n */\nexport function workBlock(\n\tpunches: { tsUtc: number; direction: string }[],\n\tnowUtc: number,\n): { hasOpenEntry: boolean; blockMinutes: number | null } {\n\tconst sorted = [...punches].sort((left, right) => left.tsUtc - right.tsUtc);\n\tlet start: number | null = null;\n\tfor (const punch of sorted) {\n\t\tstart = punch.direction === \"in\" ? punch.tsUtc : null;\n\t}\n\tif (start === null) {\n\t\treturn { hasOpenEntry: false, blockMinutes: null };\n\t}\n\treturn { hasOpenEntry: true, blockMinutes: Math.max(0, Math.floor((nowUtc - start) / 60)) };\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Automation rules of the adapter.\n *\n * The adapter checks every minute whether one of the configured rules is due. This module only *decides* \u2014 the\n * adapter loads the punches and runs the decision \u2014 so the rules stay testable without ioBroker and without a\n * clock, exactly like the trigger rules.\n *\n * The three kinds:\n * - `clockOut`: at the configured local time an employee who is still clocked in is punched out.\n * - `missingPunch`: the same moment, but only reported \u2014 nothing is written.\n * - `breakReminder`: an employee whose running work block reached the configured length is reminded.\n *\n * Every rule runs at most once per employee and period \u2014 a local date, or an ISO week for a weekly rule; the adapter\n * asks `automation_runs` before it acts. A rule can also be limited to certain weekdays.\n */\n\nimport type { AutomationRepeat, AutomationRuleRecord } from \"../db/repositories/automations\";\n\n/** What the adapter knows about one employee at the moment of a check. */\nexport interface AutomationContext {\n\t/** Local date of the employee, e.g. `2026-09-18` */\n\tlocalDate: string;\n\t/** Weekday of that date, ISO counting (1 = Monday \u2026 7 = Sunday) */\n\tweekday: number;\n\t/** Minutes since local midnight */\n\tminuteOfDay: number;\n\t/** True when the employee is clocked in */\n\thasOpenEntry: boolean;\n\t/** Minutes of the running work block, `null` when the employee is not clocked in */\n\tblockMinutes: number | null;\n\t/** True when the rule already ran for this employee today */\n\talreadyRan: boolean;\n}\n\n/** Outcome of a check. */\nexport interface AutomationDecision {\n\t/** True when the rule is due */\n\tfire: boolean;\n\t/** Why it fires or not \u2014 goes to the log and to the tests */\n\treason: string;\n}\n\n/**\n * Decides whether a rule is due for one employee.\n *\n * @param rule - rule to check\n * @param context - what the adapter knows about the employee\n * @returns the decision, including the reason for the log\n */\nexport function evaluateAutomation(rule: AutomationRuleRecord, context: AutomationContext): AutomationDecision {\n\tif (!rule.isActive) {\n\t\treturn { fire: false, reason: \"the rule is disabled\" };\n\t}\n\tif (context.alreadyRan) {\n\t\treturn { fire: false, reason: \"it already ran for this employee today\" };\n\t}\n\t// the weekday is checked before the kind: a rule for Monday to Friday stays quiet at the weekend\n\tif (!rule.weekdays.includes(context.weekday)) {\n\t\treturn { fire: false, reason: `the rule does not run on weekday ${context.weekday}` };\n\t}\n\t// a rule can be limited to a period; the dates are local days of the employee, exactly like the weekdays\n\tif (rule.activeFrom && context.localDate < rule.activeFrom) {\n\t\treturn {\n\t\t\tfire: false,\n\t\t\treason: `the rule is only valid from ${rule.activeFrom} (today is ${context.localDate})`,\n\t\t};\n\t}\n\tif (rule.activeUntil && context.localDate > rule.activeUntil) {\n\t\treturn { fire: false, reason: `the rule ended on ${rule.activeUntil} (today is ${context.localDate})` };\n\t}\n\n\tif (rule.kind === \"breakReminder\") {\n\t\tif (!context.hasOpenEntry) {\n\t\t\treturn { fire: false, reason: \"the employee is not clocked in\" };\n\t\t}\n\t\tconst length = context.blockMinutes ?? 0;\n\t\tconst wanted = rule.afterMinutes ?? 0;\n\t\treturn length >= wanted\n\t\t\t? { fire: true, reason: `working for ${length} minutes without a break` }\n\t\t\t: { fire: false, reason: `working for ${length} of ${wanted} minutes` };\n\t}\n\n\tconst wanted = rule.atMinute ?? 0;\n\tif (context.minuteOfDay < wanted) {\n\t\treturn { fire: false, reason: `waiting for minute ${wanted} of the day` };\n\t}\n\t// the mirror image of the clock out: somebody who is still missing gets a punch in\n\tif (rule.kind === \"clockIn\") {\n\t\treturn context.hasOpenEntry\n\t\t\t? { fire: false, reason: \"the employee is already clocked in\" }\n\t\t\t: { fire: true, reason: \"not clocked in yet, punching in\" };\n\t}\n\tif (!context.hasOpenEntry) {\n\t\treturn { fire: false, reason: \"the employee is not clocked in\" };\n\t}\n\treturn {\n\t\tfire: true,\n\t\treason: rule.kind === \"clockOut\" ? \"still clocked in, punching out\" : \"still clocked in\",\n\t};\n}\n\n/**\n * Weekday of a local date in ISO counting.\n *\n * @param localDate - date as `YYYY-MM-DD`\n * @returns 1 for Monday \u2026 7 for Sunday\n */\nexport function isoWeekday(localDate: string): number {\n\tconst [year, month, day] = localDate.split(\"-\").map(Number);\n\tconst weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();\n\treturn weekday === 0 ? 7 : weekday;\n}\n\n/**\n * ISO week of a local date, e.g. `2026-W38`.\n *\n * The week belongs to the year of its Thursday, which is what makes the turn of the year come out right.\n *\n * @param localDate - date as `YYYY-MM-DD`\n * @returns year and week\n */\nexport function isoWeek(localDate: string): string {\n\tconst [year, month, day] = localDate.split(\"-\").map(Number);\n\tconst date = new Date(Date.UTC(year, month - 1, day));\n\tconst thursday = new Date(date);\n\tthursday.setUTCDate(date.getUTCDate() + (4 - isoWeekday(localDate)));\n\t// the 4th of January is always in the first ISO week, so its Thursday is the reference point\n\tconst januaryFourth = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));\n\tconst firstThursday = new Date(januaryFourth);\n\tfirstThursday.setUTCDate(januaryFourth.getUTCDate() + (4 - isoWeekday(januaryFourth.toISOString().slice(0, 10))));\n\tconst week = 1 + Math.round((thursday.getTime() - firstThursday.getTime()) / (7 * 86400000));\n\treturn `${thursday.getUTCFullYear()}-W${String(week).padStart(2, \"0\")}`;\n}\n\n/**\n * Period a rule counts in.\n *\n * @param repeat - whether the rule may act once a day or once a week\n * @param localDate - date as `YYYY-MM-DD`\n * @returns the local date, or the ISO week for a weekly rule\n */\nexport function runPeriod(repeat: AutomationRepeat, localDate: string): string {\n\treturn repeat === \"week\" ? isoWeek(localDate) : localDate;\n}\n\n/**\n * Reads the running work block of an employee from the punches of the day.\n *\n * The direction hints of the punches are used, which is what the day view shows as well; the authoritative pairing\n * of a day (and therefore the punch the adapter writes) stays with `domain/punch`. A reminder is advisory, so the\n * hint is good enough here \u2014 and an employee who is not clocked in has no block at all.\n *\n * @param punches - punches of the local day, any order\n * @param nowUtc - instant of the check, UTC epoch seconds\n * @returns true when the employee is clocked in, plus the minutes of the running block\n */\nexport function workBlock(\n\tpunches: { tsUtc: number; direction: string }[],\n\tnowUtc: number,\n): { hasOpenEntry: boolean; blockMinutes: number | null } {\n\tconst sorted = [...punches].sort((left, right) => left.tsUtc - right.tsUtc);\n\tlet start: number | null = null;\n\tfor (const punch of sorted) {\n\t\tstart = punch.direction === \"in\" ? punch.tsUtc : null;\n\t}\n\tif (start === null) {\n\t\treturn { hasOpenEntry: false, blockMinutes: null };\n\t}\n\treturn { hasOpenEntry: true, blockMinutes: Math.max(0, Math.floor((nowUtc - start) / 60)) };\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["wanted"]
7
7
  }
@@ -701,6 +701,22 @@ const migrations = [
701
701
  WHERE role_id = (SELECT id FROM roles WHERE key = 'admin')
702
702
  AND permission_id = (SELECT id FROM permissions WHERE key = 'time.punch');
703
703
  `
704
+ },
705
+ /**
706
+ * A rule can be limited to a period: `active_from` and `active_until` hold local dates (`YYYY-MM-DD`).
707
+ *
708
+ * Both are optional — an empty `active_from` means “from now on”, an empty `active_until` means “without an
709
+ * end”. The date is the **local calendar day of the employee**, the same day the weekdays and the period
710
+ * guard of a run use (`local_date`), so no time zone has to be converted and the window stays in step with
711
+ * the rule time.
712
+ */
713
+ {
714
+ version: 27,
715
+ name: "automation rules: valid from and until",
716
+ sql: `
717
+ ALTER TABLE automation_rules ADD COLUMN active_from TEXT;
718
+ ALTER TABLE automation_rules ADD COLUMN active_until TEXT;
719
+ `
704
720
  }
705
721
  ];
706
722
  // 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\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\t{\n\t\tversion: 21,\n\t\tname: \"absences: approval by the administration (requested, approved, rejected)\",\n\t\trun: (db: Db): void => {\n\t\t\t// An employee requests, the administration decides. `approval` is separate from `status`, which keeps\n\t\t\t// saying whether the days are planned or already taken: a request can be planned and still wait for\n\t\t\t// its decision. Everything that exists before this migration counts as approved, so no figure changes\n\t\t\t// for a running installation; a request of an employee starts as `requested`.\n\t\t\tif (!hasColumn(db, \"absences\", \"approval\")) {\n\t\t\t\tdb.exec(\n\t\t\t\t\t\"ALTER TABLE absences ADD COLUMN approval TEXT NOT NULL DEFAULT 'approved' \" +\n\t\t\t\t\t\t\"CHECK (approval IN ('requested','approved','rejected'))\",\n\t\t\t\t);\n\t\t\t}\n\t\t\t// Who decided when and why \u2014 a rejection without a reason is not much use to the employee.\n\t\t\tif (!hasColumn(db, \"absences\", \"decided_at\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE absences ADD COLUMN decided_at INTEGER\");\n\t\t\t}\n\t\t\tif (!hasColumn(db, \"absences\", \"decided_by\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE absences ADD COLUMN decided_by INTEGER REFERENCES users(id) ON DELETE SET NULL\");\n\t\t\t}\n\t\t\tif (!hasColumn(db, \"absences\", \"decision_note\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE absences ADD COLUMN decision_note TEXT\");\n\t\t\t}\n\t\t\t// The administration looks at the open requests first, so they get an index of their own.\n\t\t\tdb.exec(\"CREATE INDEX IF NOT EXISTS idx_absences_approval ON absences(approval, date_from)\");\n\t\t},\n\t},\n\t{\n\t\tversion: 22,\n\t\tname: \"absence types: sickness, accident and military are for the administration only\",\n\t\tsql: `\n\t\t\t-- A sickness note usually reaches a company on the same day, so nobody \u201Crequests\u201D it: the administration\n\t\t\t-- books it. \u201CVisible for everybody\u201D (is_active) therefore starts switched off for these three types, so an\n\t\t\t-- employee does not find them in the app. The administration can switch it on again in the card of the\n\t\t\t-- absence types at any time.\n\t\t\tUPDATE absence_types SET is_active = 0\n\t\t\t WHERE user_id IS NULL AND code IN ('K','U','M') AND is_active = 1;\n\t\t`,\n\t},\n\t{\n\t\tversion: 23,\n\t\tname: \"users: a private token for the calendar feed\",\n\t\tsql: `\n\t\t\t-- Everybody can subscribe to the own absences in a calendar app (Google, Outlook, Apple). The feed is a\n\t\t\t-- public route with a secret in the URL, so the token belongs to one employee and can be rotated: a new\n\t\t\t-- token makes the old link invalid. A missing token simply means \u201Cno feed yet\u201D.\n\t\t\tALTER TABLE users ADD COLUMN calendar_token TEXT;\n\t\t\tCREATE UNIQUE INDEX idx_users_calendar_token ON users(calendar_token) WHERE calendar_token IS NOT NULL;\n\t\t`,\n\t},\n\t{\n\t\tversion: 24,\n\t\tname: \"absence types: a colour for the calendar\",\n\t\tsql: `\n\t\t\t-- The calendar grid marks an absence with the colour of its type, so vacation and sickness are told apart at\n\t\t\t-- a glance. The seeded types get their colour here; an empty value keeps the default of the display.\n\t\t\tALTER TABLE absence_types ADD COLUMN color TEXT;\n\t\t\tUPDATE absence_types SET color = '#2e7d32' WHERE color IS NULL AND code = 'F';\n\t\t\tUPDATE absence_types SET color = '#c62828' WHERE color IS NULL AND code = 'K';\n\t\t\tUPDATE absence_types SET color = '#ef6c00' WHERE color IS NULL AND code = 'U';\n\t\t\tUPDATE absence_types SET color = '#455a64' WHERE color IS NULL AND code = 'M';\n\t\t\tUPDATE absence_types SET color = '#1565c0' WHERE color IS NULL AND code = 'I';\n\t\t\tUPDATE absence_types SET color = '#6a1b9a' WHERE color IS NULL AND code = 'W';\n\t\t\tUPDATE absence_types SET color = '#795548' WHERE color IS NULL AND code = 'E';\n\t\t`,\n\t},\n\t{\n\t\tversion: 25,\n\t\tname: \"day notes: what an employee wants the administration to know about one day\",\n\t\tsql: `\n\t\t\t-- An employee does not change punches any more; the day of a forgotten punch is corrected by the\n\t\t\t-- administration. What the employee may do is leave a note for that day (\u201Cforgot to clock in or out\u201D),\n\t\t\t-- so the message lives outside the punches: a day without a punch is exactly the case it is meant for.\n\t\t\t-- One note per employee and day; the administration marks it as handled and keeps it for the record.\n\t\t\tCREATE TABLE day_notes (\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\tlocal_date TEXT NOT NULL,\n\t\t\t\tnote TEXT NOT NULL,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id),\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_by INTEGER REFERENCES users(id),\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\thandled_at INTEGER,\n\t\t\t\thandled_by INTEGER REFERENCES users(id)\n\t\t\t);\n\n\t\t\tCREATE UNIQUE INDEX idx_day_notes_user_date ON day_notes(user_id, local_date);\n\t\t\tCREATE INDEX idx_day_notes_open ON day_notes(handled_at, local_date);\n\t\t\t`,\n\t},\n\t{\n\t\tversion: 26,\n\t\tname: \"roles: the administrator does not punch\",\n\t\tsql: `\n\t\t\t-- The administrator account is created by the installation and belongs to nobody: it administers the\n\t\t\t-- employees instead of working with them, so it needs no punch. Punching stays with the roles that\n\t\t\t-- belong to a person: a manager corrects and punches, an employee punches. An administrator who also\n\t\t\t-- works gets the right back through the employee role, because permissions are the union of the roles\n\t\t\t-- of an account.\n\t\t\tDELETE FROM role_permissions\n\t\t\t WHERE role_id = (SELECT id FROM roles WHERE key = 'admin')\n\t\t\t AND permission_id = (SELECT id FROM permissions WHERE key = 'time.punch');\n\t\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\t{\n\t\tversion: 21,\n\t\tname: \"absences: approval by the administration (requested, approved, rejected)\",\n\t\trun: (db: Db): void => {\n\t\t\t// An employee requests, the administration decides. `approval` is separate from `status`, which keeps\n\t\t\t// saying whether the days are planned or already taken: a request can be planned and still wait for\n\t\t\t// its decision. Everything that exists before this migration counts as approved, so no figure changes\n\t\t\t// for a running installation; a request of an employee starts as `requested`.\n\t\t\tif (!hasColumn(db, \"absences\", \"approval\")) {\n\t\t\t\tdb.exec(\n\t\t\t\t\t\"ALTER TABLE absences ADD COLUMN approval TEXT NOT NULL DEFAULT 'approved' \" +\n\t\t\t\t\t\t\"CHECK (approval IN ('requested','approved','rejected'))\",\n\t\t\t\t);\n\t\t\t}\n\t\t\t// Who decided when and why \u2014 a rejection without a reason is not much use to the employee.\n\t\t\tif (!hasColumn(db, \"absences\", \"decided_at\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE absences ADD COLUMN decided_at INTEGER\");\n\t\t\t}\n\t\t\tif (!hasColumn(db, \"absences\", \"decided_by\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE absences ADD COLUMN decided_by INTEGER REFERENCES users(id) ON DELETE SET NULL\");\n\t\t\t}\n\t\t\tif (!hasColumn(db, \"absences\", \"decision_note\")) {\n\t\t\t\tdb.exec(\"ALTER TABLE absences ADD COLUMN decision_note TEXT\");\n\t\t\t}\n\t\t\t// The administration looks at the open requests first, so they get an index of their own.\n\t\t\tdb.exec(\"CREATE INDEX IF NOT EXISTS idx_absences_approval ON absences(approval, date_from)\");\n\t\t},\n\t},\n\t{\n\t\tversion: 22,\n\t\tname: \"absence types: sickness, accident and military are for the administration only\",\n\t\tsql: `\n\t\t\t-- A sickness note usually reaches a company on the same day, so nobody \u201Crequests\u201D it: the administration\n\t\t\t-- books it. \u201CVisible for everybody\u201D (is_active) therefore starts switched off for these three types, so an\n\t\t\t-- employee does not find them in the app. The administration can switch it on again in the card of the\n\t\t\t-- absence types at any time.\n\t\t\tUPDATE absence_types SET is_active = 0\n\t\t\t WHERE user_id IS NULL AND code IN ('K','U','M') AND is_active = 1;\n\t\t`,\n\t},\n\t{\n\t\tversion: 23,\n\t\tname: \"users: a private token for the calendar feed\",\n\t\tsql: `\n\t\t\t-- Everybody can subscribe to the own absences in a calendar app (Google, Outlook, Apple). The feed is a\n\t\t\t-- public route with a secret in the URL, so the token belongs to one employee and can be rotated: a new\n\t\t\t-- token makes the old link invalid. A missing token simply means \u201Cno feed yet\u201D.\n\t\t\tALTER TABLE users ADD COLUMN calendar_token TEXT;\n\t\t\tCREATE UNIQUE INDEX idx_users_calendar_token ON users(calendar_token) WHERE calendar_token IS NOT NULL;\n\t\t`,\n\t},\n\t{\n\t\tversion: 24,\n\t\tname: \"absence types: a colour for the calendar\",\n\t\tsql: `\n\t\t\t-- The calendar grid marks an absence with the colour of its type, so vacation and sickness are told apart at\n\t\t\t-- a glance. The seeded types get their colour here; an empty value keeps the default of the display.\n\t\t\tALTER TABLE absence_types ADD COLUMN color TEXT;\n\t\t\tUPDATE absence_types SET color = '#2e7d32' WHERE color IS NULL AND code = 'F';\n\t\t\tUPDATE absence_types SET color = '#c62828' WHERE color IS NULL AND code = 'K';\n\t\t\tUPDATE absence_types SET color = '#ef6c00' WHERE color IS NULL AND code = 'U';\n\t\t\tUPDATE absence_types SET color = '#455a64' WHERE color IS NULL AND code = 'M';\n\t\t\tUPDATE absence_types SET color = '#1565c0' WHERE color IS NULL AND code = 'I';\n\t\t\tUPDATE absence_types SET color = '#6a1b9a' WHERE color IS NULL AND code = 'W';\n\t\t\tUPDATE absence_types SET color = '#795548' WHERE color IS NULL AND code = 'E';\n\t\t`,\n\t},\n\t{\n\t\tversion: 25,\n\t\tname: \"day notes: what an employee wants the administration to know about one day\",\n\t\tsql: `\n\t\t\t-- An employee does not change punches any more; the day of a forgotten punch is corrected by the\n\t\t\t-- administration. What the employee may do is leave a note for that day (\u201Cforgot to clock in or out\u201D),\n\t\t\t-- so the message lives outside the punches: a day without a punch is exactly the case it is meant for.\n\t\t\t-- One note per employee and day; the administration marks it as handled and keeps it for the record.\n\t\t\tCREATE TABLE day_notes (\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\tlocal_date TEXT NOT NULL,\n\t\t\t\tnote TEXT NOT NULL,\n\t\t\t\tcreated_by INTEGER REFERENCES users(id),\n\t\t\t\tcreated_at INTEGER NOT NULL,\n\t\t\t\tupdated_by INTEGER REFERENCES users(id),\n\t\t\t\tupdated_at INTEGER NOT NULL,\n\t\t\t\thandled_at INTEGER,\n\t\t\t\thandled_by INTEGER REFERENCES users(id)\n\t\t\t);\n\n\t\t\tCREATE UNIQUE INDEX idx_day_notes_user_date ON day_notes(user_id, local_date);\n\t\t\tCREATE INDEX idx_day_notes_open ON day_notes(handled_at, local_date);\n\t\t\t`,\n\t},\n\t{\n\t\tversion: 26,\n\t\tname: \"roles: the administrator does not punch\",\n\t\tsql: `\n\t\t\t-- The administrator account is created by the installation and belongs to nobody: it administers the\n\t\t\t-- employees instead of working with them, so it needs no punch. Punching stays with the roles that\n\t\t\t-- belong to a person: a manager corrects and punches, an employee punches. An administrator who also\n\t\t\t-- works gets the right back through the employee role, because permissions are the union of the roles\n\t\t\t-- of an account.\n\t\t\tDELETE FROM role_permissions\n\t\t\t WHERE role_id = (SELECT id FROM roles WHERE key = 'admin')\n\t\t\t AND permission_id = (SELECT id FROM permissions WHERE key = 'time.punch');\n\t\t\t`,\n\t},\n\t/**\n\t * A rule can be limited to a period: `active_from` and `active_until` hold local dates (`YYYY-MM-DD`).\n\t *\n\t * Both are optional \u2014 an empty `active_from` means \u201Cfrom now on\u201D, an empty `active_until` means \u201Cwithout an\n\t * end\u201D. The date is the **local calendar day of the employee**, the same day the weekdays and the period\n\t * guard of a run use (`local_date`), so no time zone has to be converted and the window stays in step with\n\t * the rule time.\n\t */\n\t{\n\t\tversion: 27,\n\t\tname: \"automation rules: valid from and until\",\n\t\tsql: `\n\t\t\tALTER TABLE automation_rules ADD COLUMN active_from TEXT;\n\t\t\tALTER TABLE automation_rules ADD COLUMN active_until TEXT;\n\t\t`,\n\t},\n];\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -28,7 +28,7 @@ __export(automations_exports, {
28
28
  module.exports = __toCommonJS(automations_exports);
29
29
  var import_errors = require("../../errors");
30
30
  var import_audit = require("./audit");
31
- const RULE_COLUMNS = "id, label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active";
31
+ const RULE_COLUMNS = "id, label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active, active_from, active_until";
32
32
  const RUN_COLUMNS = "rule_id, user_id, period, fired_at, action";
33
33
  const AUDITED_FIELDS = [
34
34
  "label",
@@ -37,12 +37,14 @@ const AUDITED_FIELDS = [
37
37
  "atMinute",
38
38
  "afterMinutes",
39
39
  "repeat",
40
- "isActive"
40
+ "isActive",
41
+ "activeFrom",
42
+ "activeUntil"
41
43
  ];
42
44
  const KINDS = ["clockIn", "clockOut", "missingPunch", "breakReminder"];
43
45
  const TIMED_KINDS = ["clockIn", "clockOut", "missingPunch"];
44
46
  function mapAutomationRuleRow(row) {
45
- var _a;
47
+ var _a, _b, _c;
46
48
  return {
47
49
  id: row.id,
48
50
  label: row.label,
@@ -53,7 +55,9 @@ function mapAutomationRuleRow(row) {
53
55
  // a row written before the migration has no selection: every day is the sensible default
54
56
  weekdays: maskToWeekdays((_a = row.weekdays) != null ? _a : 127),
55
57
  repeat: row.repeat === "week" ? "week" : "day",
56
- isActive: row.is_active !== 0
58
+ isActive: row.is_active !== 0,
59
+ activeFrom: (_b = row.active_from) != null ? _b : null,
60
+ activeUntil: (_c = row.active_until) != null ? _c : null
57
61
  };
58
62
  }
59
63
  function mapAutomationRunRow(row) {
@@ -83,6 +87,21 @@ function requireWindow(kind, atMinute, afterMinutes) {
83
87
  }
84
88
  return { atMinute: null, afterMinutes };
85
89
  }
90
+ function requireValidityDate(value, fallback, field) {
91
+ if (value === void 0) {
92
+ return fallback;
93
+ }
94
+ const text = (value != null ? value : "").trim();
95
+ if (text === "") {
96
+ return null;
97
+ }
98
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
99
+ const date = parts ? new Date(Date.UTC(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))) : null;
100
+ if (!parts || !date || date.getUTCFullYear() !== Number(parts[1]) || date.getUTCMonth() !== Number(parts[2]) - 1 || date.getUTCDate() !== Number(parts[3])) {
101
+ throw new import_errors.ValidationError(`${field} must be a date like 2026-10-01 (got ${text})`);
102
+ }
103
+ return text;
104
+ }
86
105
  const ALL_WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
87
106
  function maskToWeekdays(mask) {
88
107
  const days = [];
@@ -124,13 +143,13 @@ function createAutomationsRepository(db) {
124
143
  const selectAll = db.prepare(`SELECT ${RULE_COLUMNS} FROM automation_rules ORDER BY id`);
125
144
  const selectActive = db.prepare(`SELECT ${RULE_COLUMNS} FROM automation_rules WHERE is_active = 1 ORDER BY id`);
126
145
  const insertRule = db.prepare(
127
- `INSERT INTO automation_rules (label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active, created_at, updated_at)
128
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
146
+ `INSERT INTO automation_rules (label, kind, user_id, at_minute, after_minutes, weekdays, repeat, is_active, active_from, active_until, created_at, updated_at)
147
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
129
148
  );
130
149
  const updateRule = db.prepare(
131
150
  `UPDATE automation_rules
132
151
  SET label = ?, kind = ?, user_id = ?, at_minute = ?, after_minutes = ?, weekdays = ?, repeat = ?,
133
- is_active = ?, updated_at = ?
152
+ is_active = ?, active_from = ?, active_until = ?, updated_at = ?
134
153
  WHERE id = ?`
135
154
  );
136
155
  const deleteRule = db.prepare("DELETE FROM automation_rules WHERE id = ?");
@@ -162,7 +181,7 @@ function createAutomationsRepository(db) {
162
181
  },
163
182
  findById: read,
164
183
  save(input) {
165
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
184
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m;
166
185
  const kind = requireKind(input.kind);
167
186
  const window = requireWindow(kind, (_a = input.atMinute) != null ? _a : null, (_b = input.afterMinutes) != null ? _b : null);
168
187
  const label = ((_c = input.label) != null ? _c : "").trim() || null;
@@ -178,8 +197,13 @@ function createAutomationsRepository(db) {
178
197
  }
179
198
  const weekdays = requireWeekdays(input.weekdays, (_f = current == null ? void 0 : current.weekdays) != null ? _f : ALL_WEEKDAYS);
180
199
  const repeat = requireRepeat(input.repeat, (_g = current == null ? void 0 : current.repeat) != null ? _g : "day");
200
+ const activeFrom = requireValidityDate(input.activeFrom, (_h = current == null ? void 0 : current.activeFrom) != null ? _h : null, "activeFrom");
201
+ const activeUntil = requireValidityDate(input.activeUntil, (_i = current == null ? void 0 : current.activeUntil) != null ? _i : null, "activeUntil");
202
+ if (activeFrom !== null && activeUntil !== null && activeUntil < activeFrom) {
203
+ throw new import_errors.ValidationError(`activeUntil must not be before activeFrom (${activeFrom} \u2026 ${activeUntil})`);
204
+ }
181
205
  const next = {
182
- id: (_h = current == null ? void 0 : current.id) != null ? _h : 0,
206
+ id: (_j = current == null ? void 0 : current.id) != null ? _j : 0,
183
207
  label,
184
208
  kind,
185
209
  userId,
@@ -187,20 +211,22 @@ function createAutomationsRepository(db) {
187
211
  afterMinutes: window.afterMinutes,
188
212
  weekdays,
189
213
  repeat,
190
- isActive
214
+ isActive,
215
+ activeFrom,
216
+ activeUntil
191
217
  };
192
218
  const changes = current ? (0, import_audit.diffFields)(
193
219
  current,
194
220
  next,
195
221
  AUDITED_FIELDS
196
222
  ) : null;
197
- if (changes && weekdaysToMask((_i = current == null ? void 0 : current.weekdays) != null ? _i : ALL_WEEKDAYS) !== weekdaysToMask(weekdays)) {
198
- changes.weekdays = { old: (_j = current == null ? void 0 : current.weekdays) != null ? _j : [], new: weekdays };
223
+ if (changes && weekdaysToMask((_k = current == null ? void 0 : current.weekdays) != null ? _k : ALL_WEEKDAYS) !== weekdaysToMask(weekdays)) {
224
+ changes.weekdays = { old: (_l = current == null ? void 0 : current.weekdays) != null ? _l : [], new: weekdays };
199
225
  }
200
226
  if (current && Object.keys(changes != null ? changes : {}).length === 0) {
201
227
  return current;
202
228
  }
203
- let ruleId = (_k = current == null ? void 0 : current.id) != null ? _k : 0;
229
+ let ruleId = (_m = current == null ? void 0 : current.id) != null ? _m : 0;
204
230
  const run = db.transaction(() => {
205
231
  var _a2;
206
232
  if (current) {
@@ -213,6 +239,8 @@ function createAutomationsRepository(db) {
213
239
  weekdaysToMask(weekdays),
214
240
  repeat,
215
241
  isActive ? 1 : 0,
242
+ activeFrom,
243
+ activeUntil,
216
244
  now,
217
245
  current.id
218
246
  );
@@ -227,6 +255,8 @@ function createAutomationsRepository(db) {
227
255
  weekdaysToMask(weekdays),
228
256
  repeat,
229
257
  isActive ? 1 : 0,
258
+ activeFrom,
259
+ activeUntil,
230
260
  now,
231
261
  now
232
262
  ).lastInsertRowid