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.
- package/README.md +67 -28
- 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 +39 -0
- package/build/lib/db/migrations.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 +1 -1
- package/build/lib/db/seed.js.map +2 -2
- package/build/lib/reports/pdf.js +8 -2
- package/build/lib/reports/pdf.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 +112 -16
- package/build/lib/web/api.js.map +2 -2
- package/build/lib/web/events.js.map +2 -2
- package/build/lib/web/problem.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 +16 -1
- package/src-pwa/src/i18n/en.json +16 -1
- package/src-pwa/src/i18n/es.json +16 -1
- package/src-pwa/src/i18n/fr.json +16 -1
- package/src-pwa/src/i18n/it.json +16 -1
- package/src-pwa/src/i18n/nl.json +16 -1
- package/src-pwa/src/i18n/pl.json +16 -1
- package/src-pwa/src/i18n/pt.json +16 -1
- package/src-pwa/src/i18n/ru.json +16 -1
- package/src-pwa/src/i18n/uk.json +16 -1
- package/src-pwa/src/i18n/zh-cn.json +16 -1
- package/www/assets/index-C0z1CkCi.js +119 -0
- package/www/assets/index-C0z1CkCi.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-DIM5i7zh.js +0 -119
- 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": ";;;;;;;;;;;;;;;;;;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
|
@@ -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
|
-
|
|
653
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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: (
|
|
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: (
|
|
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 } });
|