iobroker.time-tracker 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +67 -28
  2. package/admin/i18n/de.json +1 -1
  3. package/admin/i18n/en.json +1 -1
  4. package/admin/i18n/es.json +1 -1
  5. package/admin/i18n/fr.json +1 -1
  6. package/admin/i18n/it.json +1 -1
  7. package/admin/i18n/nl.json +1 -1
  8. package/admin/i18n/pl.json +1 -1
  9. package/admin/i18n/pt.json +1 -1
  10. package/admin/i18n/ru.json +1 -1
  11. package/admin/i18n/uk.json +1 -1
  12. package/admin/i18n/zh-cn.json +1 -1
  13. package/admin/jsonConfig.json +1 -1
  14. package/build/lib/db/migrations.js +39 -0
  15. package/build/lib/db/migrations.js.map +2 -2
  16. package/build/lib/db/repositories/dayNotes.js +183 -0
  17. package/build/lib/db/repositories/dayNotes.js.map +7 -0
  18. package/build/lib/db/seed.js +1 -1
  19. package/build/lib/db/seed.js.map +2 -2
  20. package/build/lib/reports/pdf.js +8 -2
  21. package/build/lib/reports/pdf.js.map +2 -2
  22. package/build/lib/services/sync.js +11 -9
  23. package/build/lib/services/sync.js.map +2 -2
  24. package/build/lib/web/api.js +112 -16
  25. package/build/lib/web/api.js.map +2 -2
  26. package/build/lib/web/events.js.map +2 -2
  27. package/build/lib/web/problem.js.map +2 -2
  28. package/build/main.js +4 -0
  29. package/build/main.js.map +2 -2
  30. package/io-package.json +27 -27
  31. package/package.json +1 -1
  32. package/src-pwa/src/i18n/de.json +16 -1
  33. package/src-pwa/src/i18n/en.json +16 -1
  34. package/src-pwa/src/i18n/es.json +16 -1
  35. package/src-pwa/src/i18n/fr.json +16 -1
  36. package/src-pwa/src/i18n/it.json +16 -1
  37. package/src-pwa/src/i18n/nl.json +16 -1
  38. package/src-pwa/src/i18n/pl.json +16 -1
  39. package/src-pwa/src/i18n/pt.json +16 -1
  40. package/src-pwa/src/i18n/ru.json +16 -1
  41. package/src-pwa/src/i18n/uk.json +16 -1
  42. package/src-pwa/src/i18n/zh-cn.json +16 -1
  43. package/www/assets/index-C0z1CkCi.js +119 -0
  44. package/www/assets/index-C0z1CkCi.js.map +1 -0
  45. package/www/index.html +1 -1
  46. package/www/sw.js +1 -1
  47. package/www/sw.js.map +1 -1
  48. package/www/assets/index-DIM5i7zh.js +0 -119
  49. package/www/assets/index-DIM5i7zh.js.map +0 -1
@@ -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": "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",
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
  }
@@ -394,6 +394,19 @@ function createApi(deps) {
394
394
  );
395
395
  }
396
396
  }
397
+ function requireActiveAdministrator(userId, next) {
398
+ if (next.isActive && next.roleKeys.includes("admin")) {
399
+ return;
400
+ }
401
+ const remaining = users.list().some((user) => user.id !== userId && users.roles(user.id).includes("admin"));
402
+ if (!remaining) {
403
+ throw (0, import_problem.problem)(
404
+ 409,
405
+ "last_administrator",
406
+ "the last active administrator cannot be deactivated or lose the admin role"
407
+ );
408
+ }
409
+ }
397
410
  const route = (method, path, routeSettings, handler) => {
398
411
  const methods = Array.isArray(method) ? method : [method];
399
412
  for (const entry of methods) {
@@ -510,6 +523,21 @@ function createApi(deps) {
510
523
  const raw = context.query("userId");
511
524
  return resolveScope(context, raw ? Number(raw) : null, ownPermission, otherPermission);
512
525
  };
526
+ const dayNoteTarget = (context, actorId) => {
527
+ var _a2, _b2;
528
+ const raw = context.query("userId");
529
+ const target = raw === null || raw.trim() === "" ? actorId : Number(raw);
530
+ if (!Number.isInteger(target)) {
531
+ throw new import_errors.ValidationError("userId must be a whole number");
532
+ }
533
+ if (target !== actorId && !((_b2 = (_a2 = context.auth) == null ? void 0 : _a2.permissions.includes("time.edit_other")) != null ? _b2 : false)) {
534
+ throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
535
+ }
536
+ if (!users.findById(target)) {
537
+ throw new import_errors.NotFoundError(`user ${target} not found`);
538
+ }
539
+ return target;
540
+ };
513
541
  const numberQuery = (context, name) => {
514
542
  const raw = context.query(name);
515
543
  const value = Number(raw);
@@ -612,6 +640,9 @@ function createApi(deps) {
612
640
  const result = sync.sync({
613
641
  userId: user.id,
614
642
  timeZone: user.timezone,
643
+ // the offline queue is the only way an employee still writes a punch of his own, so the window of the
644
+ // instance applies to it as well (`0` switches the bound off)
645
+ maxPastSeconds: Math.max(0, settings.getNumber("edit_window_days", 0)) * 86400,
615
646
  punches: body.punches.map((raw) => {
616
647
  const punch2 = raw != null ? raw : {};
617
648
  const tsUtc = optionalNumber(punch2, "tsUtc");
@@ -648,10 +679,13 @@ function createApi(deps) {
648
679
  "GET",
649
680
  "/entries/conflicts",
650
681
  { permission: "time.resolve_conflict" },
651
- (context) => {
652
- var _a2, _b2;
653
- return (0, import_router.json)(200, { conflicts: sync.conflicts((_b2 = (_a2 = context.auth) == null ? void 0 : _a2.user.id) != null ? _b2 : 0) });
654
- }
682
+ (context) => (
683
+ // without `?userId=` the own queue; the administration reads the queue of an employee with it, because a
684
+ // conflict of an employee waits for a decision of the office
685
+ (0, import_router.json)(200, {
686
+ conflicts: sync.conflicts(scopeUser(context, "time.resolve_conflict", "time.resolve_conflict"))
687
+ })
688
+ )
655
689
  );
656
690
  route("POST", "/entries/:id/resolve", { permission: "time.resolve_conflict", csrf: true }, (context) => {
657
691
  var _a2, _b2;
@@ -732,7 +766,7 @@ function createApi(deps) {
732
766
  }
733
767
  return (0, import_router.json)(200, { entries: entries.listByRange(userId, from, to) });
734
768
  });
735
- route("POST", "/entries", { permission: "time.edit_own", csrf: true }, (context) => {
769
+ route("POST", "/entries", { permission: "time.edit_other", csrf: true }, (context) => {
736
770
  var _a2, _b2;
737
771
  if (!context.auth) {
738
772
  throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
@@ -742,10 +776,6 @@ function createApi(deps) {
742
776
  if (!Number.isInteger(target)) {
743
777
  throw new import_errors.ValidationError("userId must be a whole number");
744
778
  }
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
779
  const user = users.findById(target);
750
780
  if (!user) {
751
781
  throw new import_errors.NotFoundError(`user ${target} not found`);
@@ -761,7 +791,8 @@ function createApi(deps) {
761
791
  tsUtc,
762
792
  clientTsUtc: optionalNumber(body, "clientTsUtc"),
763
793
  timeZone: user.timezone,
764
- source: own ? "web" : "admin",
794
+ // a hand-written punch is always a correction; the normal punch comes through `POST /punch` (`web`)
795
+ source: "admin",
765
796
  direction: optionalDirection(body),
766
797
  idempotencyKey: optionalString(body, "idempotencyKey"),
767
798
  note: optionalString(body, "note"),
@@ -778,7 +809,7 @@ function createApi(deps) {
778
809
  entryId: stored.entry.id,
779
810
  created: stored.created,
780
811
  localDate: stored.entry.localDate,
781
- source: own ? "web" : "admin"
812
+ source: "admin"
782
813
  }
783
814
  });
784
815
  return (0, import_router.json)(
@@ -1475,9 +1506,12 @@ function createApi(deps) {
1475
1506
  if (revision === null) {
1476
1507
  throw new import_errors.ValidationError("revision is required");
1477
1508
  }
1478
- requireInsideEditWindow(context, existing.userId, existing.tsUtc);
1479
1509
  const wantedTs = optionalNumber(body, "tsUtc");
1510
+ if (wantedTs !== null && !context.auth.permissions.includes("time.edit_other")) {
1511
+ throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
1512
+ }
1480
1513
  if (wantedTs !== null) {
1514
+ requireInsideEditWindow(context, existing.userId, existing.tsUtc);
1481
1515
  requireInsideEditWindow(context, existing.userId, wantedTs);
1482
1516
  }
1483
1517
  const target = users.findById(existing.userId);
@@ -1530,6 +1564,61 @@ function createApi(deps) {
1530
1564
  });
1531
1565
  return (0, import_router.noContent)();
1532
1566
  });
1567
+ route("GET", "/day-notes", { permission: "report.view_own" }, (context) => {
1568
+ var _a2;
1569
+ const userId = scopeUser(context);
1570
+ const from = context.query("from");
1571
+ const to = (_a2 = context.query("to")) != null ? _a2 : from;
1572
+ if (!from || !to) {
1573
+ throw new import_errors.ValidationError("from and to are required");
1574
+ }
1575
+ return (0, import_router.json)(200, { notes: deps.dayNotes.listByRange(userId, from, to) });
1576
+ });
1577
+ route("PUT", "/day-notes", { permission: "time.edit_own", csrf: true }, (context) => {
1578
+ var _a2, _b2;
1579
+ if (!context.auth) {
1580
+ throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
1581
+ }
1582
+ const body = context.jsonBody();
1583
+ const userId = dayNoteTarget(context, context.auth.user.id);
1584
+ const localDate2 = context.query("date");
1585
+ if (!localDate2) {
1586
+ throw new import_errors.ValidationError("date is required");
1587
+ }
1588
+ const note = deps.dayNotes.save({
1589
+ userId,
1590
+ localDate: localDate2,
1591
+ // an empty text is how a note is taken back
1592
+ note: (_a2 = optionalString(body, "note")) != null ? _a2 : "",
1593
+ actorId: context.auth.user.id,
1594
+ actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
1595
+ now: now()
1596
+ });
1597
+ emit({ type: "dayNote.change", userId, data: { localDate: localDate2 } });
1598
+ return (0, import_router.json)(200, { note });
1599
+ });
1600
+ route("POST", "/day-notes/handled", { permission: "time.edit_other", csrf: true }, (context) => {
1601
+ var _a2, _b2;
1602
+ if (!context.auth) {
1603
+ throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
1604
+ }
1605
+ const userId = dayNoteTarget(context, context.auth.user.id);
1606
+ const localDate2 = context.query("date");
1607
+ if (!localDate2) {
1608
+ throw new import_errors.ValidationError("date is required");
1609
+ }
1610
+ const handled = (_a2 = optionalBoolean(context.jsonBody(), "handled")) != null ? _a2 : true;
1611
+ const note = deps.dayNotes.setHandled({
1612
+ userId,
1613
+ localDate: localDate2,
1614
+ handled,
1615
+ actorId: context.auth.user.id,
1616
+ actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
1617
+ now: now()
1618
+ });
1619
+ emit({ type: "dayNote.change", userId, data: { localDate: localDate2 } });
1620
+ return (0, import_router.json)(200, { note });
1621
+ });
1533
1622
  route("GET", "/entries/:id/audit", { permission: "audit.view" }, (context) => {
1534
1623
  const entryId = numberParam(context, "id");
1535
1624
  if (!entries.findById(entryId)) {
@@ -1626,12 +1715,13 @@ function createApi(deps) {
1626
1715
  });
1627
1716
  });
1628
1717
  route("PATCH", "/users/:id", { permission: "user.edit", csrf: true }, (context) => {
1629
- var _a2, _b2, _c2;
1718
+ var _a2, _b2, _c2, _d2;
1630
1719
  if (!context.auth) {
1631
1720
  throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
1632
1721
  }
1633
1722
  const id = numberParam(context, "id");
1634
- if (!users.findById(id)) {
1723
+ const target = users.findById(id);
1724
+ if (!target) {
1635
1725
  throw new import_errors.NotFoundError(`user ${id} not found`);
1636
1726
  }
1637
1727
  const body = context.jsonBody();
@@ -1694,19 +1784,25 @@ function createApi(deps) {
1694
1784
  now: now()
1695
1785
  });
1696
1786
  }
1787
+ if (patch.isActive === false || roleKeys !== null) {
1788
+ requireActiveAdministrator(id, {
1789
+ isActive: (_b2 = patch.isActive) != null ? _b2 : target.isActive,
1790
+ roleKeys: roleKeys != null ? roleKeys : users.roles(id)
1791
+ });
1792
+ }
1697
1793
  const updated = users.update({
1698
1794
  id,
1699
1795
  patch,
1700
1796
  reason: optionalString(body, "reason"),
1701
1797
  actorId: context.auth.user.id,
1702
- actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
1798
+ actorIp: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
1703
1799
  now: now()
1704
1800
  });
1705
1801
  const roles = roleKeys ? users.setRoles({
1706
1802
  userId: id,
1707
1803
  roleKeys,
1708
1804
  actorId: context.auth.user.id,
1709
- actorIp: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
1805
+ actorIp: (_d2 = context.request.remoteAddress) != null ? _d2 : null,
1710
1806
  now: now()
1711
1807
  }) : users.roles(id);
1712
1808
  return (0, import_router.json)(200, { user: { ...publicUser(updated), roles } });