iobroker.time-tracker 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -26
- package/admin/i18n/de.json +1 -1
- package/admin/i18n/en.json +1 -1
- package/admin/i18n/es.json +1 -1
- package/admin/i18n/fr.json +1 -1
- package/admin/i18n/it.json +1 -1
- package/admin/i18n/nl.json +1 -1
- package/admin/i18n/pl.json +1 -1
- package/admin/i18n/pt.json +1 -1
- package/admin/i18n/ru.json +1 -1
- package/admin/i18n/uk.json +1 -1
- package/admin/i18n/zh-cn.json +1 -1
- package/admin/jsonConfig.json +1 -1
- package/build/lib/db/migrations.js +55 -0
- package/build/lib/db/migrations.js.map +2 -2
- package/build/lib/db/repositories/absences.js +29 -13
- package/build/lib/db/repositories/absences.js.map +2 -2
- package/build/lib/db/repositories/dayNotes.js +183 -0
- package/build/lib/db/repositories/dayNotes.js.map +7 -0
- package/build/lib/db/seed.js +18 -11
- package/build/lib/db/seed.js.map +2 -2
- package/build/lib/services/sync.js +11 -9
- package/build/lib/services/sync.js.map +2 -2
- package/build/lib/web/api.js +89 -12
- package/build/lib/web/api.js.map +2 -2
- package/build/lib/web/events.js.map +2 -2
- package/build/main.js +4 -0
- package/build/main.js.map +2 -2
- package/io-package.json +27 -27
- package/package.json +1 -1
- package/src-pwa/src/i18n/de.json +15 -3
- package/src-pwa/src/i18n/en.json +15 -3
- package/src-pwa/src/i18n/es.json +15 -3
- package/src-pwa/src/i18n/fr.json +15 -3
- package/src-pwa/src/i18n/it.json +15 -3
- package/src-pwa/src/i18n/nl.json +15 -3
- package/src-pwa/src/i18n/pl.json +15 -3
- package/src-pwa/src/i18n/pt.json +15 -3
- package/src-pwa/src/i18n/ru.json +15 -3
- package/src-pwa/src/i18n/uk.json +15 -3
- package/src-pwa/src/i18n/zh-cn.json +15 -3
- package/www/assets/index-D5fSC8CA.js +119 -0
- package/www/assets/index-D5fSC8CA.js.map +1 -0
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
- package/www/assets/index-DtdFg3O-.js +0 -119
- package/www/assets/index-DtdFg3O-.js.map +0 -1
package/build/lib/db/seed.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/db/seed.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Initial data (seeds): roles, permission catalogue, absence types, instance settings and holidays.\n *\n * All seed operations are idempotent (`INSERT OR IGNORE`), so `seed()` can run on every adapter start.\n */\n\nimport type { Db } from \"./database\";\nimport { holidaysForYear, type HolidayCountry } from \"../domain/holidays\";\n\n/** Permission catalogue (keys are stored in `permissions.key`). */\nexport const PERMISSIONS = {\n\ttime: [\"time.punch\", \"time.edit_own\", \"time.edit_other\", \"time.delete\", \"time.import\", \"time.resolve_conflict\"],\n\tabsence: [\"absence.request\", \"absence.approve\", \"absence.edit_other\", \"absence.manage_types\"],\n\tuser: [\"user.view\", \"user.create\", \"user.edit\", \"user.deactivate\", \"user.manage_roles\"],\n\treport: [\"report.view_own\", \"report.view_other\", \"report.export\", \"report.statistics\", \"report.print_locked\"],\n\tpayout: [\"payout.view\", \"payout.create\"],\n\tsystem: [\n\t\t\"settings.view\",\n\t\t\"settings.edit\",\n\t\t\"rfid.manage\",\n\t\t\"terminal.manage\",\n\t\t\"holiday.manage\",\n\t\t\"import.run\",\n\t\t\"audit.view\",\n\t\t\"backup.run\",\n\t],\n\tcommand: [\"command.punch\", \"command.close_month\", \"command.recalc\"],\n} as const;\n\nexport const ALL_PERMISSIONS: string[] = Object.values(PERMISSIONS).flat();\n\n
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOA,sBAAqD;AAG9C,MAAM,cAAc;AAAA,EAC1B,MAAM,CAAC,cAAc,iBAAiB,mBAAmB,eAAe,eAAe,uBAAuB;AAAA,EAC9G,SAAS,CAAC,mBAAmB,mBAAmB,sBAAsB,sBAAsB;AAAA,EAC5F,MAAM,CAAC,aAAa,eAAe,aAAa,mBAAmB,mBAAmB;AAAA,EACtF,QAAQ,CAAC,mBAAmB,qBAAqB,iBAAiB,qBAAqB,qBAAqB;AAAA,EAC5G,QAAQ,CAAC,eAAe,eAAe;AAAA,EACvC,QAAQ;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACD;AAAA,EACA,SAAS,CAAC,iBAAiB,uBAAuB,gBAAgB;AACnE;AAEO,MAAM,kBAA4B,OAAO,OAAO,WAAW,EAAE,KAAK;
|
|
4
|
+
"sourcesContent": ["/**\n * Initial data (seeds): roles, permission catalogue, absence types, instance settings and holidays.\n *\n * All seed operations are idempotent (`INSERT OR IGNORE`), so `seed()` can run on every adapter start.\n */\n\nimport type { Db } from \"./database\";\nimport { holidaysForYear, type HolidayCountry } from \"../domain/holidays\";\n\n/** Permission catalogue (keys are stored in `permissions.key`). */\nexport const PERMISSIONS = {\n\ttime: [\"time.punch\", \"time.edit_own\", \"time.edit_other\", \"time.delete\", \"time.import\", \"time.resolve_conflict\"],\n\tabsence: [\"absence.request\", \"absence.approve\", \"absence.edit_other\", \"absence.manage_types\"],\n\tuser: [\"user.view\", \"user.create\", \"user.edit\", \"user.deactivate\", \"user.manage_roles\"],\n\treport: [\"report.view_own\", \"report.view_other\", \"report.export\", \"report.statistics\", \"report.print_locked\"],\n\tpayout: [\"payout.view\", \"payout.create\"],\n\tsystem: [\n\t\t\"settings.view\",\n\t\t\"settings.edit\",\n\t\t\"rfid.manage\",\n\t\t\"terminal.manage\",\n\t\t\"holiday.manage\",\n\t\t\"import.run\",\n\t\t\"audit.view\",\n\t\t\"backup.run\",\n\t],\n\tcommand: [\"command.punch\", \"command.close_month\", \"command.recalc\"],\n} as const;\n\nexport const ALL_PERMISSIONS: string[] = Object.values(PERMISSIONS).flat();\n\n/**\n * Role \u2192 permissions (specification section 4.8).\n *\n * The administrator account is created by the installation and belongs to nobody: it administers the employees\n * instead of working with them and therefore does not punch (migration 26 takes the right away from installations\n * that already exist). Whoever both administers and works carries the `employee` role as well \u2014 the right comes\n * back with it, because permissions are the union of the roles of an account.\n */\nexport const ROLE_PERMISSIONS: Record<string, string[]> = {\n\tadmin: ALL_PERMISSIONS.filter(permission => permission !== \"time.punch\"),\n\tmanager: [\n\t\t\"time.punch\",\n\t\t\"time.edit_own\",\n\t\t\"time.edit_other\",\n\t\t\"time.delete\",\n\t\t\"time.resolve_conflict\",\n\t\t\"absence.request\",\n\t\t\"absence.approve\",\n\t\t\"absence.edit_other\",\n\t\t\"absence.manage_types\",\n\t\t\"user.view\",\n\t\t\"report.view_own\",\n\t\t\"report.view_other\",\n\t\t\"report.export\",\n\t\t\"report.statistics\",\n\t\t\"report.print_locked\",\n\t\t\"payout.view\",\n\t\t\"settings.view\",\n\t\t\"holiday.manage\",\n\t\t\"audit.view\",\n\t\t\"command.punch\",\n\t\t\"command.close_month\",\n\t\t\"command.recalc\",\n\t],\n\temployee: [\"time.punch\", \"time.edit_own\", \"absence.request\", \"report.view_own\"],\n};\n\n/**\n * Default absence types of the time tracking: code, name, factor, reduces vacation, visible for everybody.\n *\n * \u201CVisible for everybody\u201D (`active`) says whether an employee may pick the type in the app. Sickness, accident and\n * military service start switched off: a company gets those notes on the same day and books them itself, while\n * vacation, further training and the like are requested by the employee.\n */\nexport const ABSENCE_TYPES: {\n\tcode: string;\n\tname: string;\n\tfactor: number;\n\treduceVacation: boolean;\n\tactive: boolean;\n\tcolor: string;\n}[] = [\n\t{ code: \"F\", name: \"Ferien\", factor: 100, reduceVacation: true, active: true, color: \"#2e7d32\" },\n\t{ code: \"K\", name: \"Krankheit\", factor: 100, reduceVacation: false, active: false, color: \"#c62828\" },\n\t{ code: \"U\", name: \"Unfall\", factor: 100, reduceVacation: false, active: false, color: \"#ef6c00\" },\n\t{ code: \"M\", name: \"Milit\u00E4r\", factor: 100, reduceVacation: false, active: false, color: \"#455a64\" },\n\t{ code: \"I\", name: \"Intern\", factor: 100, reduceVacation: false, active: true, color: \"#1565c0\" },\n\t{ code: \"W\", name: \"Weiterbildung\", factor: 50, reduceVacation: false, active: true, color: \"#6a1b9a\" },\n\t{ code: \"E\", name: \"Extern\", factor: 50, reduceVacation: false, active: true, color: \"#795548\" },\n];\n\n/** Default instance settings (specification section 2.9.9); existing values are never overwritten. */\nexport const SETTING_DEFAULTS: Record<string, string> = {\n\tedit_window_days: \"7\",\n\tquick_round_minutes: \"0\",\n\tabsence_calc_until_today: \"1\",\n\tabsence_deduct_worktime: \"0\",\n\tprint_limit_days: \"0\",\n\tholiday_country: \"DE\",\n\tdefault_language: \"de\",\n\tsession_ttl_minutes: \"720\",\n\tbackup_retention_days: \"30\",\n\tattendance_list_visible: \"0\",\n\t// how the pause of a day is determined: `auto` = the punched break, otherwise the graduated rules\n\tpause_mode: \"auto\",\n\t// path of a Unicode font for the PDF statement; empty uses the built-in Latin fonts\n\treport_font_path: \"\",\n\t// branding of the installation: logo and background as data URLs (empty = not set), accent colour as hex\n\tbrand_logo: \"\",\n\tbrand_background: \"\",\n\tbrand_color: \"\",\n};\n\n/**\n * Options controlling which initial data is written.\n */\nexport interface SeedOptions {\n\t/** Country used to generate public holidays (default `CH`) */\n\tholidayCountry?: HolidayCountry;\n\t/** Years for which holidays are generated (default: current and next year) */\n\tholidayYears?: number[];\n}\n\n/**\n * Writes all initial data. Idempotent: repeated calls change nothing and never overwrite\n * user-modified settings.\n *\n * @param db - open database handle\n * @param options - seed options\n * @returns summary of what is now present\n */\nexport function seed(db: Db, options: SeedOptions = {}): { permissions: number; holidays: number } {\n\tconst country: HolidayCountry = options.holidayCountry ?? \"DE\";\n\tconst currentYear = new Date().getUTCFullYear();\n\tconst years = options.holidayYears ?? [currentYear, currentYear + 1];\n\n\tconst run = db.transaction((): void => {\n\t\tconst now = Math.floor(Date.now() / 1000);\n\n\t\tconst insertRole = db.prepare(\"INSERT OR IGNORE INTO roles (key, name) VALUES (?, ?)\");\n\t\tinsertRole.run(\"admin\", \"Administrator\");\n\t\tinsertRole.run(\"manager\", \"Manager\");\n\t\tinsertRole.run(\"employee\", \"Employee\");\n\n\t\tconst insertPermission = db.prepare(\"INSERT OR IGNORE INTO permissions (key) VALUES (?)\");\n\t\tfor (const permission of ALL_PERMISSIONS) {\n\t\t\tinsertPermission.run(permission);\n\t\t}\n\n\t\tconst findRole = db.prepare(\"SELECT id FROM roles WHERE key = ?\");\n\t\tconst findPermission = db.prepare(\"SELECT id FROM permissions WHERE key = ?\");\n\t\tconst linkRole = db.prepare(\"INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)\");\n\t\tfor (const [roleKey, permissions] of Object.entries(ROLE_PERMISSIONS)) {\n\t\t\tconst role = findRole.get(roleKey) as { id: number } | undefined;\n\t\t\tif (!role) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const permission of permissions) {\n\t\t\t\tconst row = findPermission.get(permission) as { id: number } | undefined;\n\t\t\t\tif (row) {\n\t\t\t\t\tlinkRole.run(role.id, row.id);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst insertAbsenceType = db.prepare(\n\t\t\t`INSERT OR IGNORE INTO absence_types (user_id, code, name, paid, factor, reduce_vacation, is_active, color)\n\t\t\t VALUES (NULL, ?, ?, 1, ?, ?, ?, ?)`,\n\t\t);\n\t\tfor (const type of ABSENCE_TYPES) {\n\t\t\tinsertAbsenceType.run(\n\t\t\t\ttype.code,\n\t\t\t\ttype.name,\n\t\t\t\ttype.factor,\n\t\t\t\ttype.reduceVacation ? 1 : 0,\n\t\t\t\ttype.active ? 1 : 0,\n\t\t\t\ttype.color,\n\t\t\t);\n\t\t}\n\n\t\tconst insertSetting = db.prepare(\n\t\t\t\"INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)\",\n\t\t);\n\t\tfor (const [key, value] of Object.entries(SETTING_DEFAULTS)) {\n\t\t\tinsertSetting.run(key, key === \"holiday_country\" ? country : value, now);\n\t\t}\n\t});\n\trun();\n\n\tlet holidayCount = 0;\n\tfor (const year of years) {\n\t\tholidayCount += ensureHolidaysForYear(db, country, year);\n\t}\n\n\tconst permissionCount = (db.prepare(\"SELECT COUNT(*) AS c FROM permissions\").get() as { c: number }).c;\n\treturn { permissions: permissionCount, holidays: holidayCount };\n}\n\n/**\n * Generates and stores the public holidays of one year for a country (idempotent).\n *\n * @param db - open database handle\n * @param country - supported country code\n * @param year - four digit year\n * @returns number of inserted holiday rows\n */\nexport function ensureHolidaysForYear(db: Db, country: HolidayCountry, year: number): number {\n\tconst insert = db.prepare(\"INSERT OR IGNORE INTO holidays (region, year, date, name, key) VALUES (?, ?, ?, ?, ?)\");\n\tlet inserted = 0;\n\tconst run = db.transaction((): void => {\n\t\tfor (const holiday of holidaysForYear(year, country)) {\n\t\t\tinserted += insert.run(country, year, holiday.date, holiday.name, holiday.key).changes;\n\t\t}\n\t});\n\trun();\n\treturn inserted;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -50,20 +50,21 @@ function createSyncService(deps) {
|
|
|
50
50
|
};
|
|
51
51
|
return {
|
|
52
52
|
sync(input) {
|
|
53
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
|
|
53
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m;
|
|
54
54
|
const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
|
|
55
55
|
const maxSkew = (_b = input.maxClockSkewSeconds) != null ? _b : 300;
|
|
56
56
|
const minDistance = (_c = input.minDistanceSeconds) != null ? _c : 30;
|
|
57
57
|
const maxFuture = (_d = input.maxFutureSeconds) != null ? _d : 12 * 3600;
|
|
58
|
+
const maxPast = (_e = input.maxPastSeconds) != null ? _e : 0;
|
|
58
59
|
const profile = users.getWorkProfile(input.userId);
|
|
59
|
-
const window = { startDate: (
|
|
60
|
+
const window = { startDate: (_f = profile == null ? void 0 : profile.startDate) != null ? _f : null, endDate: (_g = profile == null ? void 0 : profile.endDate) != null ? _g : null };
|
|
60
61
|
const accepted = [];
|
|
61
62
|
const conflicts = [];
|
|
62
63
|
const rejected = [];
|
|
63
64
|
const touched = /* @__PURE__ */ new Set();
|
|
64
65
|
const sorted = input.punches.slice().sort((a, b) => a.tsUtc - b.tsUtc || a.idempotencyKey.localeCompare(b.idempotencyKey));
|
|
65
66
|
for (const punch of sorted) {
|
|
66
|
-
const key = (
|
|
67
|
+
const key = (_i = (_h = punch.idempotencyKey) == null ? void 0 : _h.trim()) != null ? _i : "";
|
|
67
68
|
if (!key) {
|
|
68
69
|
rejected.push({ idempotencyKey: "", reason: "missing_key", detail: "idempotencyKey is required" });
|
|
69
70
|
continue;
|
|
@@ -102,26 +103,27 @@ function createSyncService(deps) {
|
|
|
102
103
|
}
|
|
103
104
|
const skew = punch.clientTsUtc == null ? 0 : Math.abs(punch.tsUtc - punch.clientTsUtc);
|
|
104
105
|
const windowError = (0, import_punch.checkEmploymentWindow)(punch.tsUtc, window);
|
|
105
|
-
const
|
|
106
|
+
const tooOld = maxPast > 0 && punch.tsUtc < now - maxPast;
|
|
107
|
+
const syncState = skew > maxSkew || windowError || tooOld ? "conflict" : "synced";
|
|
106
108
|
const stored = entries.insert({
|
|
107
109
|
userId: input.userId,
|
|
108
110
|
tsUtc: punch.tsUtc,
|
|
109
|
-
clientTsUtc: (
|
|
111
|
+
clientTsUtc: (_j = punch.clientTsUtc) != null ? _j : null,
|
|
110
112
|
timeZone: input.timeZone,
|
|
111
113
|
source: "api",
|
|
112
|
-
direction: (
|
|
114
|
+
direction: (_k = punch.direction) != null ? _k : "auto",
|
|
113
115
|
idempotencyKey: key,
|
|
114
116
|
syncState,
|
|
115
|
-
note: (
|
|
117
|
+
note: (_l = punch.note) != null ? _l : null,
|
|
116
118
|
actorId: input.actorId,
|
|
117
|
-
actorIp: (
|
|
119
|
+
actorIp: (_m = input.actorIp) != null ? _m : null,
|
|
118
120
|
now
|
|
119
121
|
});
|
|
120
122
|
touched.add(stored.entry.localDate);
|
|
121
123
|
if (syncState === "conflict") {
|
|
122
124
|
conflicts.push({
|
|
123
125
|
idempotencyKey: key,
|
|
124
|
-
reason: windowError ? "employment_window" : "clock_skew",
|
|
126
|
+
reason: tooOld ? "too_old" : windowError ? "employment_window" : "clock_skew",
|
|
125
127
|
entryId: stored.entry.id,
|
|
126
128
|
entry: stored.entry
|
|
127
129
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/services/sync.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Synchronisation service for offline punches.\n *\n * The PWA and the kiosk terminal queue punches locally while they have no connection and send them as a\n * batch afterwards. Every punch carries an idempotency key, so a repeated batch never creates duplicates.\n *\n * Punches that cannot be trusted are **not** discarded: they are stored with `sync_state = conflict` (they do\n * not count in the calculation) and reported back to the caller, so an employee or a manager can resolve them\n * later. Only punches that are clearly invalid (missing key, unusable instant, far in the future) are\n * rejected outright.\n *\n * A \"cancellation pair\" is a pair of neighbouring punches that lie within a tolerance of each other \u2014 usually\n * the result of an accidental double punch. Such pairs can be removed together.\n */\n\nimport type { Db } from \"../db/database\";\nimport type { EntriesRepository, EntryDirection, EntryRecord, EntrySyncState } from \"../db/repositories/entries\";\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport { NotFoundError, ValidationError } from \"../errors\";\nimport { writeAuditLog } from \"../db/repositories/audit\";\nimport { checkEmploymentWindow } from \"../domain/punch\";\nimport { addDays, localDate as resolveLocalDate } from \"../util/time\";\nimport type { AggregationService } from \"./aggregation\";\n\n/** Reason why a punch was not accepted as it came in. */\nexport type SyncConflictReason =\n\t\"missing_key\" | \"invalid\" | \"future\" | \"duplicate_punch\" | \"clock_skew\" | \"employment_window\";\n\n/** A single punch of an offline batch. */\nexport interface SyncPunchInput {\n\t/** Idempotency key generated by the client (mandatory for batches) */\n\tidempotencyKey: string;\n\t/** Punch instant, UTC epoch seconds (device time) */\n\ttsUtc: number;\n\t/** Instant reported by the client clock (used for the skew check) */\n\tclientTsUtc?: number | null;\n\t/** Direction hint for the UI */\n\tdirection?: EntryDirection;\n\t/** Free-form note */\n\tnote?: string | null;\n}\n\n/** Input of a synchronisation batch. */\nexport interface SyncBatchInput {\n\t/** Employee the punches belong to */\n\tuserId: number;\n\t/** Time zone used to derive the local cache fields */\n\ttimeZone: string;\n\t/** Queued punches (order does not matter, they are sorted by instant) */\n\tpunches: SyncPunchInput[];\n\t/** Who triggers the synchronisation (the employee itself or an admin) */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Difference between device and server time tolerated before flagging (`300` seconds) */\n\tmaxClockSkewSeconds?: number;\n\t/** Punches closer than this to an existing punch are duplicates (`30` seconds) */\n\tminDistanceSeconds?: number;\n\t/** Punches further in the future than this are rejected (`12` hours) */\n\tmaxFutureSeconds?: number;\n\t/** Instant of the synchronisation, defaults to now */\n\tnow?: number;\n}\n\n/** Punches that were stored (or already existed). */\nexport interface SyncAccepted {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Id of the stored punch */\n\tentryId: number;\n\t/** False when the punch was already stored earlier */\n\tcreated: boolean;\n\t/** Resulting synchronisation state */\n\tsyncState: EntrySyncState;\n\t/** Local date the punch belongs to */\n\tlocalDate: string;\n}\n\n/** Punches that were stored but need a decision. */\nexport interface SyncConflict {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the conflict */\n\treason: SyncConflictReason;\n\t/** Id of the stored punch, `null` when nothing was stored */\n\tentryId: number | null;\n\t/** Stored punch, `null` when nothing was stored */\n\tentry: EntryRecord | null;\n}\n\n/** Punches that were not stored at all. */\nexport interface SyncRejected {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the rejection */\n\treason: SyncConflictReason;\n\t/** Human readable detail (e.g. the offending instant) */\n\tdetail: string;\n}\n\n/** Result of a synchronisation batch. */\nexport interface SyncBatchResult {\n\t/** Stored punches, sorted by instant */\n\taccepted: SyncAccepted[];\n\t/** Punches stored as conflict */\n\tconflicts: SyncConflict[];\n\t/** Punches that were not stored */\n\trejected: SyncRejected[];\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Input for resolving a conflict. */\nexport interface ResolveConflictInput {\n\t/** Id of the punch in conflict */\n\tentryId: number;\n\t/** `accept` keeps the punch (optionally with a corrected instant), `dismiss` deletes it */\n\taction: \"accept\" | \"dismiss\";\n\t/** Corrected instant (UTC epoch seconds) used when accepting */\n\ttsUtc?: number;\n\t/** Reason recorded in the audit trail */\n\treason?: string | null;\n\t/** Who resolves the conflict */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Time zone used to recompute the local cache fields when the instant changes */\n\ttimeZone?: string;\n\t/** Instant of the decision, defaults to now */\n\tnow?: number;\n}\n\n/** Result of resolving a conflict. */\nexport interface ResolveConflictResult {\n\t/** Applied action */\n\taction: \"accept\" | \"dismiss\";\n\t/** Punch after the decision, `null` after a dismissal */\n\tentry: EntryRecord | null;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Two neighbouring punches that cancel each other out. */\nexport interface CancellationPair {\n\t/** Earlier punch */\n\tfirst: EntryRecord;\n\t/** Later punch */\n\tsecond: EntryRecord;\n\t/** Distance between both punches in seconds */\n\tdistanceSeconds: number;\n}\n\n/** Result of removing a cancellation pair. */\nexport interface CancelPairResult {\n\t/** Number of deleted punches */\n\tremoved: number;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Synchronisation operations. */\nexport interface SyncService {\n\t/** Processes an offline batch */\n\tsync(input: SyncBatchInput): SyncBatchResult;\n\t/** Open conflicts of an employee, newest first */\n\tconflicts(userId: number): EntryRecord[];\n\t/** Accepts or dismisses a conflict */\n\tresolve(input: ResolveConflictInput): ResolveConflictResult;\n\t/** Finds neighbouring punches that cancel each other out */\n\tfindCancellationPairs(\n\t\tuserId: number,\n\t\tfromDate: string,\n\t\ttoDate: string,\n\t\toptions?: { maxDistanceSeconds?: number },\n\t): CancellationPair[];\n\t/** Removes both punches of a cancellation pair */\n\tcancelPair(input: {\n\t\tentryIds: [number, number];\n\t\treason?: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): CancelPairResult;\n}\n\n/** Data sources of the synchronisation service. */\nexport interface SyncDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** Punch storage */\n\tentries: EntriesRepository;\n\t/** User storage (employment window) */\n\tusers: UsersRepository;\n\t/** Aggregation service used to refresh the affected days */\n\taggregation: AggregationService;\n}\n\n/**\n * Creates the synchronisation service.\n *\n * @param deps - data sources\n * @returns service instance\n */\nexport function createSyncService(deps: SyncDeps): SyncService {\n\tconst { db, entries, users, aggregation } = deps;\n\n\t/**\n\t * Recalculates the given local dates once.\n\t *\n\t * @param userId - owner of the days\n\t * @param dates - local dates to refresh\n\t * @returns the refreshed dates, sorted\n\t */\n\tconst refreshDays = (userId: number, dates: Iterable<string>): string[] => {\n\t\tconst unique = [...new Set(dates)].sort();\n\t\tfor (const date of unique) {\n\t\t\taggregation.recalculateDay(userId, date);\n\t\t}\n\t\treturn unique;\n\t};\n\n\t/**\n\t * Time zone of an employee (fallback `UTC`).\n\t *\n\t * @param userId - owner of the punches\n\t * @returns IANA time zone\n\t */\n\tconst timeZoneOf = (userId: number): string => users.findById(userId)?.timezone ?? \"UTC\";\n\n\t/**\n\t * Looks for an existing punch close to the given instant.\n\t *\n\t * @param userId - owner of the punches\n\t * @param tsUtc - instant to check\n\t * @param minDistance - tolerated distance in seconds\n\t * @param timeZone - time zone used to find the neighbouring days\n\t * @returns the nearby punch or `null`\n\t */\n\tconst findNearby = (userId: number, tsUtc: number, minDistance: number, timeZone: string): EntryRecord | null => {\n\t\tconst date = resolveLocalDate(tsUtc, timeZone);\n\t\tconst candidates = [\n\t\t\t...entries.listByDate(userId, addDays(date, -1)),\n\t\t\t...entries.listByDate(userId, date),\n\t\t\t...entries.listByDate(userId, addDays(date, 1)),\n\t\t];\n\t\treturn candidates.find(entry => Math.abs(entry.tsUtc - tsUtc) < minDistance) ?? null;\n\t};\n\n\treturn {\n\t\tsync(input: SyncBatchInput): SyncBatchResult {\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst maxSkew = input.maxClockSkewSeconds ?? 300;\n\t\t\tconst minDistance = input.minDistanceSeconds ?? 30;\n\t\t\tconst maxFuture = input.maxFutureSeconds ?? 12 * 3600;\n\t\t\tconst profile = users.getWorkProfile(input.userId);\n\t\t\tconst window = { startDate: profile?.startDate ?? null, endDate: profile?.endDate ?? null };\n\n\t\t\tconst accepted: SyncAccepted[] = [];\n\t\t\tconst conflicts: SyncConflict[] = [];\n\t\t\tconst rejected: SyncRejected[] = [];\n\t\t\tconst touched = new Set<string>();\n\n\t\t\tconst sorted = input.punches\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.idempotencyKey.localeCompare(b.idempotencyKey));\n\n\t\t\tfor (const punch of sorted) {\n\t\t\t\tconst key = punch.idempotencyKey?.trim() ?? \"\";\n\t\t\t\tif (!key) {\n\t\t\t\t\trejected.push({ idempotencyKey: \"\", reason: \"missing_key\", detail: \"idempotencyKey is required\" });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (!Number.isFinite(punch.tsUtc) || punch.tsUtc <= 0) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"invalid\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is not a usable instant`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (punch.tsUtc > now + maxFuture) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"future\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is more than ${maxFuture} seconds in the future`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// a punch that was already transferred is acknowledged without a second write\n\t\t\t\tconst alreadyStored = entries.findByIdempotencyKey(input.userId, key);\n\t\t\t\tif (alreadyStored) {\n\t\t\t\t\taccepted.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\tentryId: alreadyStored.id,\n\t\t\t\t\t\tcreated: false,\n\t\t\t\t\t\tsyncState: alreadyStored.syncState,\n\t\t\t\t\t\tlocalDate: alreadyStored.localDate,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// an unrelated punch at almost the same instant is a duplicate and needs a decision\n\t\t\t\tconst duplicate = findNearby(input.userId, punch.tsUtc, minDistance, input.timeZone);\n\t\t\t\tif (duplicate) {\n\t\t\t\t\tconflicts.push({ idempotencyKey: key, reason: \"duplicate_punch\", entryId: null, entry: null });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst skew = punch.clientTsUtc == null ? 0 : Math.abs(punch.tsUtc - punch.clientTsUtc);\n\t\t\t\tconst windowError = checkEmploymentWindow(punch.tsUtc, window);\n\t\t\t\tconst syncState: EntrySyncState = skew > maxSkew || windowError ? \"conflict\" : \"synced\";\n\n\t\t\t\tconst stored = entries.insert({\n\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\ttsUtc: punch.tsUtc,\n\t\t\t\t\tclientTsUtc: punch.clientTsUtc ?? null,\n\t\t\t\t\ttimeZone: input.timeZone,\n\t\t\t\t\tsource: \"api\",\n\t\t\t\t\tdirection: punch.direction ?? \"auto\",\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tsyncState,\n\t\t\t\t\tnote: punch.note ?? null,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\ttouched.add(stored.entry.localDate);\n\n\t\t\t\tif (syncState === \"conflict\") {\n\t\t\t\t\tconflicts.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: windowError ? \"employment_window\" : \"clock_skew\",\n\t\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\t\tentry: stored.entry,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\taccepted.push({\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\tcreated: stored.created,\n\t\t\t\t\tsyncState: stored.entry.syncState,\n\t\t\t\t\tlocalDate: stored.entry.localDate,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn { accepted, conflicts, rejected, recalculated: refreshDays(input.userId, touched) };\n\t\t},\n\n\t\tconflicts(userId: number): EntryRecord[] {\n\t\t\treturn entries.listBySyncState(userId, \"conflict\");\n\t\t},\n\n\t\tresolve(input: ResolveConflictInput): ResolveConflictResult {\n\t\t\tconst current = entries.findById(input.entryId);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`entry ${input.entryId} not found`);\n\t\t\t}\n\t\t\tif (current.syncState !== \"conflict\") {\n\t\t\t\tthrow new ValidationError(`entry ${input.entryId} is not in conflict (state ${current.syncState})`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst touched = new Set<string>([current.localDate]);\n\t\t\tlet entry: EntryRecord | null = current;\n\n\t\t\tif (input.action === \"accept\") {\n\t\t\t\tif (input.tsUtc !== undefined && input.tsUtc !== current.tsUtc) {\n\t\t\t\t\tconst moved = entries.update({\n\t\t\t\t\t\tid: current.id,\n\t\t\t\t\t\texpectedRevision: current.revision,\n\t\t\t\t\t\tpatch: { tsUtc: input.tsUtc },\n\t\t\t\t\t\treason: input.reason ?? \"clock skew corrected\",\n\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\ttimeZone: input.timeZone ?? timeZoneOf(current.userId),\n\t\t\t\t\t\tnow,\n\t\t\t\t\t});\n\t\t\t\t\ttouched.add(moved.localDate);\n\t\t\t\t\ttouched.add(current.localDate);\n\t\t\t\t}\n\t\t\t\tentry = entries.setSyncState({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tsyncState: \"synced\",\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? null,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_accept\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: entry.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tentries.remove({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? \"conflict dismissed\",\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_dismiss\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: current.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t\tentry = null;\n\t\t\t}\n\n\t\t\treturn { action: input.action, entry, recalculated: refreshDays(current.userId, touched) };\n\t\t},\n\n\t\tfindCancellationPairs(\n\t\t\tuserId: number,\n\t\t\tfromDate: string,\n\t\t\ttoDate: string,\n\t\t\toptions?: { maxDistanceSeconds?: number },\n\t\t): CancellationPair[] {\n\t\t\tconst maxDistance = options?.maxDistanceSeconds ?? 120;\n\t\t\tconst punches = entries\n\t\t\t\t.listByRange(userId, fromDate, toDate)\n\t\t\t\t.filter(entry => entry.syncState === \"synced\")\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.id - b.id);\n\n\t\t\tconst pairs: CancellationPair[] = [];\n\t\t\tlet index = 0;\n\t\t\twhile (index + 1 < punches.length) {\n\t\t\t\tconst first = punches[index];\n\t\t\t\tconst second = punches[index + 1];\n\t\t\t\tconst distanceSeconds = second.tsUtc - first.tsUtc;\n\t\t\t\tif (distanceSeconds >= 0 && distanceSeconds <= maxDistance) {\n\t\t\t\t\tpairs.push({ first, second, distanceSeconds });\n\t\t\t\t\t// both punches are used, so the next candidate starts after them\n\t\t\t\t\tindex += 2;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tindex++;\n\t\t\t}\n\t\t\treturn pairs;\n\t\t},\n\n\t\tcancelPair(input: {\n\t\t\tentryIds: [number, number];\n\t\t\treason?: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): CancelPairResult {\n\t\t\tconst first = entries.findById(input.entryIds[0]);\n\t\t\tconst second = entries.findById(input.entryIds[1]);\n\t\t\tif (!first || !second) {\n\t\t\t\tthrow new NotFoundError(\"both punches of the cancellation pair must exist\");\n\t\t\t}\n\t\t\tif (first.userId !== second.userId) {\n\t\t\t\tthrow new ValidationError(\"both punches of the cancellation pair must belong to the same employee\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst reason = input.reason ?? \"cancellation pair\";\n\t\t\tconst touched = new Set<string>([first.localDate, second.localDate]);\n\t\t\tlet removed = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const entry of [first, second]) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tentries.remove({\n\t\t\t\t\t\t\tid: entry.id,\n\t\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\t\treason,\n\t\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\t\tnow,\n\t\t\t\t\t\t})\n\t\t\t\t\t) {\n\t\t\t\t\t\tremoved++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.cancel_pair\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: `${first.id}+${second.id}`,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: first.userId,\n\t\t\t\t\t\tpunch: { first: first.tsUtc, second: second.tsUtc },\n\t\t\t\t\t\tdistanceSeconds: second.tsUtc - first.tsUtc,\n\t\t\t\t\t\treason,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn { removed, recalculated: refreshDays(first.userId, touched) };\n\t\t},\n\t};\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBA,oBAA+C;AAC/C,mBAA8B;AAC9B,mBAAsC;AACtC,kBAAuD;
|
|
4
|
+
"sourcesContent": ["/**\n * Synchronisation service for offline punches.\n *\n * The PWA and the kiosk terminal queue punches locally while they have no connection and send them as a\n * batch afterwards. Every punch carries an idempotency key, so a repeated batch never creates duplicates.\n *\n * Punches that cannot be trusted are **not** discarded: they are stored with `sync_state = conflict` (they do\n * not count in the calculation) and reported back to the caller, so an employee or a manager can resolve them\n * later. Only punches that are clearly invalid (missing key, unusable instant, far in the future) are\n * rejected outright.\n *\n * A \"cancellation pair\" is a pair of neighbouring punches that lie within a tolerance of each other \u2014 usually\n * the result of an accidental double punch. Such pairs can be removed together.\n */\n\nimport type { Db } from \"../db/database\";\nimport type { EntriesRepository, EntryDirection, EntryRecord, EntrySyncState } from \"../db/repositories/entries\";\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport { NotFoundError, ValidationError } from \"../errors\";\nimport { writeAuditLog } from \"../db/repositories/audit\";\nimport { checkEmploymentWindow } from \"../domain/punch\";\nimport { addDays, localDate as resolveLocalDate } from \"../util/time\";\nimport type { AggregationService } from \"./aggregation\";\n\n/** Reason why a punch was not accepted as it came in. */\nexport type SyncConflictReason =\n\t\"missing_key\" | \"invalid\" | \"future\" | \"too_old\" | \"duplicate_punch\" | \"clock_skew\" | \"employment_window\";\n\n/** A single punch of an offline batch. */\nexport interface SyncPunchInput {\n\t/** Idempotency key generated by the client (mandatory for batches) */\n\tidempotencyKey: string;\n\t/** Punch instant, UTC epoch seconds (device time) */\n\ttsUtc: number;\n\t/** Instant reported by the client clock (used for the skew check) */\n\tclientTsUtc?: number | null;\n\t/** Direction hint for the UI */\n\tdirection?: EntryDirection;\n\t/** Free-form note */\n\tnote?: string | null;\n}\n\n/** Input of a synchronisation batch. */\nexport interface SyncBatchInput {\n\t/** Employee the punches belong to */\n\tuserId: number;\n\t/** Time zone used to derive the local cache fields */\n\ttimeZone: string;\n\t/** Queued punches (order does not matter, they are sorted by instant) */\n\tpunches: SyncPunchInput[];\n\t/** Who triggers the synchronisation (the employee itself or an admin) */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Difference between device and server time tolerated before flagging (`300` seconds) */\n\tmaxClockSkewSeconds?: number;\n\t/** Punches closer than this to an existing punch are duplicates (`30` seconds) */\n\tminDistanceSeconds?: number;\n\t/** Punches further in the future than this are rejected (`12` hours) */\n\tmaxFutureSeconds?: number;\n\t/**\n\t * Queued punches older than this are stored as a conflict instead of counting (`0` = no bound).\n\t *\n\t * The offline queue is the only way an employee still writes a punch of his own, so the same window that\n\t * bounds an own change applies here: an older punch reaches the day and waits for the administration.\n\t */\n\tmaxPastSeconds?: number;\n\t/** Instant of the synchronisation, defaults to now */\n\tnow?: number;\n}\n\n/** Punches that were stored (or already existed). */\nexport interface SyncAccepted {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Id of the stored punch */\n\tentryId: number;\n\t/** False when the punch was already stored earlier */\n\tcreated: boolean;\n\t/** Resulting synchronisation state */\n\tsyncState: EntrySyncState;\n\t/** Local date the punch belongs to */\n\tlocalDate: string;\n}\n\n/** Punches that were stored but need a decision. */\nexport interface SyncConflict {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the conflict */\n\treason: SyncConflictReason;\n\t/** Id of the stored punch, `null` when nothing was stored */\n\tentryId: number | null;\n\t/** Stored punch, `null` when nothing was stored */\n\tentry: EntryRecord | null;\n}\n\n/** Punches that were not stored at all. */\nexport interface SyncRejected {\n\t/** Key of the client */\n\tidempotencyKey: string;\n\t/** Reason of the rejection */\n\treason: SyncConflictReason;\n\t/** Human readable detail (e.g. the offending instant) */\n\tdetail: string;\n}\n\n/** Result of a synchronisation batch. */\nexport interface SyncBatchResult {\n\t/** Stored punches, sorted by instant */\n\taccepted: SyncAccepted[];\n\t/** Punches stored as conflict */\n\tconflicts: SyncConflict[];\n\t/** Punches that were not stored */\n\trejected: SyncRejected[];\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Input for resolving a conflict. */\nexport interface ResolveConflictInput {\n\t/** Id of the punch in conflict */\n\tentryId: number;\n\t/** `accept` keeps the punch (optionally with a corrected instant), `dismiss` deletes it */\n\taction: \"accept\" | \"dismiss\";\n\t/** Corrected instant (UTC epoch seconds) used when accepting */\n\ttsUtc?: number;\n\t/** Reason recorded in the audit trail */\n\treason?: string | null;\n\t/** Who resolves the conflict */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Time zone used to recompute the local cache fields when the instant changes */\n\ttimeZone?: string;\n\t/** Instant of the decision, defaults to now */\n\tnow?: number;\n}\n\n/** Result of resolving a conflict. */\nexport interface ResolveConflictResult {\n\t/** Applied action */\n\taction: \"accept\" | \"dismiss\";\n\t/** Punch after the decision, `null` after a dismissal */\n\tentry: EntryRecord | null;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Two neighbouring punches that cancel each other out. */\nexport interface CancellationPair {\n\t/** Earlier punch */\n\tfirst: EntryRecord;\n\t/** Later punch */\n\tsecond: EntryRecord;\n\t/** Distance between both punches in seconds */\n\tdistanceSeconds: number;\n}\n\n/** Result of removing a cancellation pair. */\nexport interface CancelPairResult {\n\t/** Number of deleted punches */\n\tremoved: number;\n\t/** Local dates whose day aggregate was refreshed */\n\trecalculated: string[];\n}\n\n/** Synchronisation operations. */\nexport interface SyncService {\n\t/** Processes an offline batch */\n\tsync(input: SyncBatchInput): SyncBatchResult;\n\t/** Open conflicts of an employee, newest first */\n\tconflicts(userId: number): EntryRecord[];\n\t/** Accepts or dismisses a conflict */\n\tresolve(input: ResolveConflictInput): ResolveConflictResult;\n\t/** Finds neighbouring punches that cancel each other out */\n\tfindCancellationPairs(\n\t\tuserId: number,\n\t\tfromDate: string,\n\t\ttoDate: string,\n\t\toptions?: { maxDistanceSeconds?: number },\n\t): CancellationPair[];\n\t/** Removes both punches of a cancellation pair */\n\tcancelPair(input: {\n\t\tentryIds: [number, number];\n\t\treason?: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): CancelPairResult;\n}\n\n/** Data sources of the synchronisation service. */\nexport interface SyncDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** Punch storage */\n\tentries: EntriesRepository;\n\t/** User storage (employment window) */\n\tusers: UsersRepository;\n\t/** Aggregation service used to refresh the affected days */\n\taggregation: AggregationService;\n}\n\n/**\n * Creates the synchronisation service.\n *\n * @param deps - data sources\n * @returns service instance\n */\nexport function createSyncService(deps: SyncDeps): SyncService {\n\tconst { db, entries, users, aggregation } = deps;\n\n\t/**\n\t * Recalculates the given local dates once.\n\t *\n\t * @param userId - owner of the days\n\t * @param dates - local dates to refresh\n\t * @returns the refreshed dates, sorted\n\t */\n\tconst refreshDays = (userId: number, dates: Iterable<string>): string[] => {\n\t\tconst unique = [...new Set(dates)].sort();\n\t\tfor (const date of unique) {\n\t\t\taggregation.recalculateDay(userId, date);\n\t\t}\n\t\treturn unique;\n\t};\n\n\t/**\n\t * Time zone of an employee (fallback `UTC`).\n\t *\n\t * @param userId - owner of the punches\n\t * @returns IANA time zone\n\t */\n\tconst timeZoneOf = (userId: number): string => users.findById(userId)?.timezone ?? \"UTC\";\n\n\t/**\n\t * Looks for an existing punch close to the given instant.\n\t *\n\t * @param userId - owner of the punches\n\t * @param tsUtc - instant to check\n\t * @param minDistance - tolerated distance in seconds\n\t * @param timeZone - time zone used to find the neighbouring days\n\t * @returns the nearby punch or `null`\n\t */\n\tconst findNearby = (userId: number, tsUtc: number, minDistance: number, timeZone: string): EntryRecord | null => {\n\t\tconst date = resolveLocalDate(tsUtc, timeZone);\n\t\tconst candidates = [\n\t\t\t...entries.listByDate(userId, addDays(date, -1)),\n\t\t\t...entries.listByDate(userId, date),\n\t\t\t...entries.listByDate(userId, addDays(date, 1)),\n\t\t];\n\t\treturn candidates.find(entry => Math.abs(entry.tsUtc - tsUtc) < minDistance) ?? null;\n\t};\n\n\treturn {\n\t\tsync(input: SyncBatchInput): SyncBatchResult {\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst maxSkew = input.maxClockSkewSeconds ?? 300;\n\t\t\tconst minDistance = input.minDistanceSeconds ?? 30;\n\t\t\tconst maxFuture = input.maxFutureSeconds ?? 12 * 3600;\n\t\t\tconst maxPast = input.maxPastSeconds ?? 0;\n\t\t\tconst profile = users.getWorkProfile(input.userId);\n\t\t\tconst window = { startDate: profile?.startDate ?? null, endDate: profile?.endDate ?? null };\n\n\t\t\tconst accepted: SyncAccepted[] = [];\n\t\t\tconst conflicts: SyncConflict[] = [];\n\t\t\tconst rejected: SyncRejected[] = [];\n\t\t\tconst touched = new Set<string>();\n\n\t\t\tconst sorted = input.punches\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.idempotencyKey.localeCompare(b.idempotencyKey));\n\n\t\t\tfor (const punch of sorted) {\n\t\t\t\tconst key = punch.idempotencyKey?.trim() ?? \"\";\n\t\t\t\tif (!key) {\n\t\t\t\t\trejected.push({ idempotencyKey: \"\", reason: \"missing_key\", detail: \"idempotencyKey is required\" });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (!Number.isFinite(punch.tsUtc) || punch.tsUtc <= 0) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"invalid\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is not a usable instant`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (punch.tsUtc > now + maxFuture) {\n\t\t\t\t\trejected.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: \"future\",\n\t\t\t\t\t\tdetail: `tsUtc ${punch.tsUtc} is more than ${maxFuture} seconds in the future`,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// a punch that was already transferred is acknowledged without a second write\n\t\t\t\tconst alreadyStored = entries.findByIdempotencyKey(input.userId, key);\n\t\t\t\tif (alreadyStored) {\n\t\t\t\t\taccepted.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\tentryId: alreadyStored.id,\n\t\t\t\t\t\tcreated: false,\n\t\t\t\t\t\tsyncState: alreadyStored.syncState,\n\t\t\t\t\t\tlocalDate: alreadyStored.localDate,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// an unrelated punch at almost the same instant is a duplicate and needs a decision\n\t\t\t\tconst duplicate = findNearby(input.userId, punch.tsUtc, minDistance, input.timeZone);\n\t\t\t\tif (duplicate) {\n\t\t\t\t\tconflicts.push({ idempotencyKey: key, reason: \"duplicate_punch\", entryId: null, entry: null });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst skew = punch.clientTsUtc == null ? 0 : Math.abs(punch.tsUtc - punch.clientTsUtc);\n\t\t\t\tconst windowError = checkEmploymentWindow(punch.tsUtc, window);\n\t\t\t\t// a queued punch that is older than the window is not lost: it is stored and waits for a decision\n\t\t\t\t// of the administration instead of counting right away\n\t\t\t\tconst tooOld = maxPast > 0 && punch.tsUtc < now - maxPast;\n\t\t\t\tconst syncState: EntrySyncState = skew > maxSkew || windowError || tooOld ? \"conflict\" : \"synced\";\n\n\t\t\t\tconst stored = entries.insert({\n\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\ttsUtc: punch.tsUtc,\n\t\t\t\t\tclientTsUtc: punch.clientTsUtc ?? null,\n\t\t\t\t\ttimeZone: input.timeZone,\n\t\t\t\t\tsource: \"api\",\n\t\t\t\t\tdirection: punch.direction ?? \"auto\",\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tsyncState,\n\t\t\t\t\tnote: punch.note ?? null,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\ttouched.add(stored.entry.localDate);\n\n\t\t\t\tif (syncState === \"conflict\") {\n\t\t\t\t\tconflicts.push({\n\t\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\t\treason: tooOld ? \"too_old\" : windowError ? \"employment_window\" : \"clock_skew\",\n\t\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\t\tentry: stored.entry,\n\t\t\t\t\t});\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\taccepted.push({\n\t\t\t\t\tidempotencyKey: key,\n\t\t\t\t\tentryId: stored.entry.id,\n\t\t\t\t\tcreated: stored.created,\n\t\t\t\t\tsyncState: stored.entry.syncState,\n\t\t\t\t\tlocalDate: stored.entry.localDate,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn { accepted, conflicts, rejected, recalculated: refreshDays(input.userId, touched) };\n\t\t},\n\n\t\tconflicts(userId: number): EntryRecord[] {\n\t\t\treturn entries.listBySyncState(userId, \"conflict\");\n\t\t},\n\n\t\tresolve(input: ResolveConflictInput): ResolveConflictResult {\n\t\t\tconst current = entries.findById(input.entryId);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`entry ${input.entryId} not found`);\n\t\t\t}\n\t\t\tif (current.syncState !== \"conflict\") {\n\t\t\t\tthrow new ValidationError(`entry ${input.entryId} is not in conflict (state ${current.syncState})`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst touched = new Set<string>([current.localDate]);\n\t\t\tlet entry: EntryRecord | null = current;\n\n\t\t\tif (input.action === \"accept\") {\n\t\t\t\tif (input.tsUtc !== undefined && input.tsUtc !== current.tsUtc) {\n\t\t\t\t\tconst moved = entries.update({\n\t\t\t\t\t\tid: current.id,\n\t\t\t\t\t\texpectedRevision: current.revision,\n\t\t\t\t\t\tpatch: { tsUtc: input.tsUtc },\n\t\t\t\t\t\treason: input.reason ?? \"clock skew corrected\",\n\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\ttimeZone: input.timeZone ?? timeZoneOf(current.userId),\n\t\t\t\t\t\tnow,\n\t\t\t\t\t});\n\t\t\t\t\ttouched.add(moved.localDate);\n\t\t\t\t\ttouched.add(current.localDate);\n\t\t\t\t}\n\t\t\t\tentry = entries.setSyncState({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tsyncState: \"synced\",\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? null,\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_accept\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: entry.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tentries.remove({\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\treason: input.reason ?? \"conflict dismissed\",\n\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t});\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.conflict_dismiss\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: current.id,\n\t\t\t\t\tdetail: { userId: current.userId, tsUtc: current.tsUtc, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t\tentry = null;\n\t\t\t}\n\n\t\t\treturn { action: input.action, entry, recalculated: refreshDays(current.userId, touched) };\n\t\t},\n\n\t\tfindCancellationPairs(\n\t\t\tuserId: number,\n\t\t\tfromDate: string,\n\t\t\ttoDate: string,\n\t\t\toptions?: { maxDistanceSeconds?: number },\n\t\t): CancellationPair[] {\n\t\t\tconst maxDistance = options?.maxDistanceSeconds ?? 120;\n\t\t\tconst punches = entries\n\t\t\t\t.listByRange(userId, fromDate, toDate)\n\t\t\t\t.filter(entry => entry.syncState === \"synced\")\n\t\t\t\t.slice()\n\t\t\t\t.sort((a, b) => a.tsUtc - b.tsUtc || a.id - b.id);\n\n\t\t\tconst pairs: CancellationPair[] = [];\n\t\t\tlet index = 0;\n\t\t\twhile (index + 1 < punches.length) {\n\t\t\t\tconst first = punches[index];\n\t\t\t\tconst second = punches[index + 1];\n\t\t\t\tconst distanceSeconds = second.tsUtc - first.tsUtc;\n\t\t\t\tif (distanceSeconds >= 0 && distanceSeconds <= maxDistance) {\n\t\t\t\t\tpairs.push({ first, second, distanceSeconds });\n\t\t\t\t\t// both punches are used, so the next candidate starts after them\n\t\t\t\t\tindex += 2;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tindex++;\n\t\t\t}\n\t\t\treturn pairs;\n\t\t},\n\n\t\tcancelPair(input: {\n\t\t\tentryIds: [number, number];\n\t\t\treason?: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): CancelPairResult {\n\t\t\tconst first = entries.findById(input.entryIds[0]);\n\t\t\tconst second = entries.findById(input.entryIds[1]);\n\t\t\tif (!first || !second) {\n\t\t\t\tthrow new NotFoundError(\"both punches of the cancellation pair must exist\");\n\t\t\t}\n\t\t\tif (first.userId !== second.userId) {\n\t\t\t\tthrow new ValidationError(\"both punches of the cancellation pair must belong to the same employee\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst reason = input.reason ?? \"cancellation pair\";\n\t\t\tconst touched = new Set<string>([first.localDate, second.localDate]);\n\t\t\tlet removed = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const entry of [first, second]) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tentries.remove({\n\t\t\t\t\t\t\tid: entry.id,\n\t\t\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\t\t\treason,\n\t\t\t\t\t\t\tactorIp: input.actorIp ?? null,\n\t\t\t\t\t\t\tnow,\n\t\t\t\t\t\t})\n\t\t\t\t\t) {\n\t\t\t\t\t\tremoved++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"entry.cancel_pair\",\n\t\t\t\t\tentity: \"time_entry\",\n\t\t\t\t\tentityId: `${first.id}+${second.id}`,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: first.userId,\n\t\t\t\t\t\tpunch: { first: first.tsUtc, second: second.tsUtc },\n\t\t\t\t\t\tdistanceSeconds: second.tsUtc - first.tsUtc,\n\t\t\t\t\t\treason,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn { removed, recalculated: refreshDays(first.userId, touched) };\n\t\t},\n\t};\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["resolveLocalDate", "_a", "_b"]
|
|
7
7
|
}
|
package/build/lib/web/api.js
CHANGED
|
@@ -510,6 +510,21 @@ function createApi(deps) {
|
|
|
510
510
|
const raw = context.query("userId");
|
|
511
511
|
return resolveScope(context, raw ? Number(raw) : null, ownPermission, otherPermission);
|
|
512
512
|
};
|
|
513
|
+
const dayNoteTarget = (context, actorId) => {
|
|
514
|
+
var _a2, _b2;
|
|
515
|
+
const raw = context.query("userId");
|
|
516
|
+
const target = raw === null || raw.trim() === "" ? actorId : Number(raw);
|
|
517
|
+
if (!Number.isInteger(target)) {
|
|
518
|
+
throw new import_errors.ValidationError("userId must be a whole number");
|
|
519
|
+
}
|
|
520
|
+
if (target !== actorId && !((_b2 = (_a2 = context.auth) == null ? void 0 : _a2.permissions.includes("time.edit_other")) != null ? _b2 : false)) {
|
|
521
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
|
|
522
|
+
}
|
|
523
|
+
if (!users.findById(target)) {
|
|
524
|
+
throw new import_errors.NotFoundError(`user ${target} not found`);
|
|
525
|
+
}
|
|
526
|
+
return target;
|
|
527
|
+
};
|
|
513
528
|
const numberQuery = (context, name) => {
|
|
514
529
|
const raw = context.query(name);
|
|
515
530
|
const value = Number(raw);
|
|
@@ -612,6 +627,9 @@ function createApi(deps) {
|
|
|
612
627
|
const result = sync.sync({
|
|
613
628
|
userId: user.id,
|
|
614
629
|
timeZone: user.timezone,
|
|
630
|
+
// the offline queue is the only way an employee still writes a punch of his own, so the window of the
|
|
631
|
+
// instance applies to it as well (`0` switches the bound off)
|
|
632
|
+
maxPastSeconds: Math.max(0, settings.getNumber("edit_window_days", 0)) * 86400,
|
|
615
633
|
punches: body.punches.map((raw) => {
|
|
616
634
|
const punch2 = raw != null ? raw : {};
|
|
617
635
|
const tsUtc = optionalNumber(punch2, "tsUtc");
|
|
@@ -648,10 +666,13 @@ function createApi(deps) {
|
|
|
648
666
|
"GET",
|
|
649
667
|
"/entries/conflicts",
|
|
650
668
|
{ permission: "time.resolve_conflict" },
|
|
651
|
-
(context) =>
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
669
|
+
(context) => (
|
|
670
|
+
// without `?userId=` the own queue; the administration reads the queue of an employee with it, because a
|
|
671
|
+
// conflict of an employee waits for a decision of the office
|
|
672
|
+
(0, import_router.json)(200, {
|
|
673
|
+
conflicts: sync.conflicts(scopeUser(context, "time.resolve_conflict", "time.resolve_conflict"))
|
|
674
|
+
})
|
|
675
|
+
)
|
|
655
676
|
);
|
|
656
677
|
route("POST", "/entries/:id/resolve", { permission: "time.resolve_conflict", csrf: true }, (context) => {
|
|
657
678
|
var _a2, _b2;
|
|
@@ -732,7 +753,7 @@ function createApi(deps) {
|
|
|
732
753
|
}
|
|
733
754
|
return (0, import_router.json)(200, { entries: entries.listByRange(userId, from, to) });
|
|
734
755
|
});
|
|
735
|
-
route("POST", "/entries", { permission: "time.
|
|
756
|
+
route("POST", "/entries", { permission: "time.edit_other", csrf: true }, (context) => {
|
|
736
757
|
var _a2, _b2;
|
|
737
758
|
if (!context.auth) {
|
|
738
759
|
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
@@ -742,10 +763,6 @@ function createApi(deps) {
|
|
|
742
763
|
if (!Number.isInteger(target)) {
|
|
743
764
|
throw new import_errors.ValidationError("userId must be a whole number");
|
|
744
765
|
}
|
|
745
|
-
const own = target === context.auth.user.id;
|
|
746
|
-
if (!own && !context.auth.permissions.includes("time.edit_other")) {
|
|
747
|
-
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
|
|
748
|
-
}
|
|
749
766
|
const user = users.findById(target);
|
|
750
767
|
if (!user) {
|
|
751
768
|
throw new import_errors.NotFoundError(`user ${target} not found`);
|
|
@@ -761,7 +778,8 @@ function createApi(deps) {
|
|
|
761
778
|
tsUtc,
|
|
762
779
|
clientTsUtc: optionalNumber(body, "clientTsUtc"),
|
|
763
780
|
timeZone: user.timezone,
|
|
764
|
-
|
|
781
|
+
// a hand-written punch is always a correction; the normal punch comes through `POST /punch` (`web`)
|
|
782
|
+
source: "admin",
|
|
765
783
|
direction: optionalDirection(body),
|
|
766
784
|
idempotencyKey: optionalString(body, "idempotencyKey"),
|
|
767
785
|
note: optionalString(body, "note"),
|
|
@@ -778,7 +796,7 @@ function createApi(deps) {
|
|
|
778
796
|
entryId: stored.entry.id,
|
|
779
797
|
created: stored.created,
|
|
780
798
|
localDate: stored.entry.localDate,
|
|
781
|
-
source:
|
|
799
|
+
source: "admin"
|
|
782
800
|
}
|
|
783
801
|
});
|
|
784
802
|
return (0, import_router.json)(
|
|
@@ -1090,6 +1108,7 @@ function createApi(deps) {
|
|
|
1090
1108
|
factor: (_b2 = optionalNumber(body, "factor")) != null ? _b2 : void 0,
|
|
1091
1109
|
reduceVacation: (_c2 = optionalBoolean(body, "reduceVacation")) != null ? _c2 : void 0,
|
|
1092
1110
|
isActive: (_d2 = optionalBoolean(body, "isActive")) != null ? _d2 : void 0,
|
|
1111
|
+
color: optionalString(body, "color"),
|
|
1093
1112
|
actorId: context.auth.user.id,
|
|
1094
1113
|
actorIp: (_e = context.request.remoteAddress) != null ? _e : null,
|
|
1095
1114
|
now: now()
|
|
@@ -1474,9 +1493,12 @@ function createApi(deps) {
|
|
|
1474
1493
|
if (revision === null) {
|
|
1475
1494
|
throw new import_errors.ValidationError("revision is required");
|
|
1476
1495
|
}
|
|
1477
|
-
requireInsideEditWindow(context, existing.userId, existing.tsUtc);
|
|
1478
1496
|
const wantedTs = optionalNumber(body, "tsUtc");
|
|
1497
|
+
if (wantedTs !== null && !context.auth.permissions.includes("time.edit_other")) {
|
|
1498
|
+
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
|
|
1499
|
+
}
|
|
1479
1500
|
if (wantedTs !== null) {
|
|
1501
|
+
requireInsideEditWindow(context, existing.userId, existing.tsUtc);
|
|
1480
1502
|
requireInsideEditWindow(context, existing.userId, wantedTs);
|
|
1481
1503
|
}
|
|
1482
1504
|
const target = users.findById(existing.userId);
|
|
@@ -1529,6 +1551,61 @@ function createApi(deps) {
|
|
|
1529
1551
|
});
|
|
1530
1552
|
return (0, import_router.noContent)();
|
|
1531
1553
|
});
|
|
1554
|
+
route("GET", "/day-notes", { permission: "report.view_own" }, (context) => {
|
|
1555
|
+
var _a2;
|
|
1556
|
+
const userId = scopeUser(context);
|
|
1557
|
+
const from = context.query("from");
|
|
1558
|
+
const to = (_a2 = context.query("to")) != null ? _a2 : from;
|
|
1559
|
+
if (!from || !to) {
|
|
1560
|
+
throw new import_errors.ValidationError("from and to are required");
|
|
1561
|
+
}
|
|
1562
|
+
return (0, import_router.json)(200, { notes: deps.dayNotes.listByRange(userId, from, to) });
|
|
1563
|
+
});
|
|
1564
|
+
route("PUT", "/day-notes", { permission: "time.edit_own", csrf: true }, (context) => {
|
|
1565
|
+
var _a2, _b2;
|
|
1566
|
+
if (!context.auth) {
|
|
1567
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1568
|
+
}
|
|
1569
|
+
const body = context.jsonBody();
|
|
1570
|
+
const userId = dayNoteTarget(context, context.auth.user.id);
|
|
1571
|
+
const localDate2 = context.query("date");
|
|
1572
|
+
if (!localDate2) {
|
|
1573
|
+
throw new import_errors.ValidationError("date is required");
|
|
1574
|
+
}
|
|
1575
|
+
const note = deps.dayNotes.save({
|
|
1576
|
+
userId,
|
|
1577
|
+
localDate: localDate2,
|
|
1578
|
+
// an empty text is how a note is taken back
|
|
1579
|
+
note: (_a2 = optionalString(body, "note")) != null ? _a2 : "",
|
|
1580
|
+
actorId: context.auth.user.id,
|
|
1581
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
1582
|
+
now: now()
|
|
1583
|
+
});
|
|
1584
|
+
emit({ type: "dayNote.change", userId, data: { localDate: localDate2 } });
|
|
1585
|
+
return (0, import_router.json)(200, { note });
|
|
1586
|
+
});
|
|
1587
|
+
route("POST", "/day-notes/handled", { permission: "time.edit_other", csrf: true }, (context) => {
|
|
1588
|
+
var _a2, _b2;
|
|
1589
|
+
if (!context.auth) {
|
|
1590
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1591
|
+
}
|
|
1592
|
+
const userId = dayNoteTarget(context, context.auth.user.id);
|
|
1593
|
+
const localDate2 = context.query("date");
|
|
1594
|
+
if (!localDate2) {
|
|
1595
|
+
throw new import_errors.ValidationError("date is required");
|
|
1596
|
+
}
|
|
1597
|
+
const handled = (_a2 = optionalBoolean(context.jsonBody(), "handled")) != null ? _a2 : true;
|
|
1598
|
+
const note = deps.dayNotes.setHandled({
|
|
1599
|
+
userId,
|
|
1600
|
+
localDate: localDate2,
|
|
1601
|
+
handled,
|
|
1602
|
+
actorId: context.auth.user.id,
|
|
1603
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
1604
|
+
now: now()
|
|
1605
|
+
});
|
|
1606
|
+
emit({ type: "dayNote.change", userId, data: { localDate: localDate2 } });
|
|
1607
|
+
return (0, import_router.json)(200, { note });
|
|
1608
|
+
});
|
|
1532
1609
|
route("GET", "/entries/:id/audit", { permission: "audit.view" }, (context) => {
|
|
1533
1610
|
const entryId = numberParam(context, "id");
|
|
1534
1611
|
if (!entries.findById(entryId)) {
|