iobroker.time-tracker 0.2.6 → 0.3.0
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 +25 -14
- package/build/lib/adapter/commands.js +18 -3
- package/build/lib/adapter/commands.js.map +2 -2
- package/build/lib/adapter/messages.js +11 -2
- package/build/lib/adapter/messages.js.map +2 -2
- package/build/lib/db/migrations.js +9 -0
- package/build/lib/db/migrations.js.map +2 -2
- package/build/lib/db/repositories/users.js +13 -7
- package/build/lib/db/repositories/users.js.map +2 -2
- package/build/lib/services/aggregation.js +2 -1
- package/build/lib/services/aggregation.js.map +2 -2
- package/build/lib/web/api.js +17 -5
- package/build/lib/web/api.js.map +2 -2
- package/build/main.js +38 -3
- 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 +4 -1
- package/src-pwa/src/i18n/en.json +4 -1
- package/src-pwa/src/i18n/es.json +4 -1
- package/src-pwa/src/i18n/fr.json +4 -1
- package/src-pwa/src/i18n/it.json +4 -1
- package/src-pwa/src/i18n/nl.json +4 -1
- package/src-pwa/src/i18n/pl.json +4 -1
- package/src-pwa/src/i18n/pt.json +4 -1
- package/src-pwa/src/i18n/ru.json +4 -1
- package/src-pwa/src/i18n/uk.json +4 -1
- package/src-pwa/src/i18n/zh-cn.json +4 -1
- package/www/assets/index-BW4HvYbk.js +119 -0
- package/www/assets/index-BW4HvYbk.js.map +1 -0
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
- package/www/assets/index-QI3wrEvW.js +0 -119
- package/www/assets/index-QI3wrEvW.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/lib/db/repositories/users.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * User repository (`users`, `user_roles`, `work_profiles`).\n *\n * Every write records an audit entry in the same transaction; passwords and password hashes never end up\n * in the audit trail, only the fact that they were changed.\n */\n\nimport type { Db } from \"../database\";\nimport type { OvertimeModel } from \"../../domain/calculation\";\nimport type { WorkProfile } from \"../../domain/target\";\nimport { NotFoundError, ValidationError } from \"../../errors\";\nimport { writeAuditLog, diffFields } from \"./audit\";\n\n/** A user as stored in the database. */\nexport interface UserRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Unique login name */\n\tlogin: string;\n\t/** Hash of the password (never leaves the server) */\n\tpasswordHash: string;\n\t/** Name shown in the UI */\n\tdisplayName: string;\n\t/** E-mail address */\n\temail: string | null;\n\t/** RFID card id */\n\trfidCard: string | null;\n\t/** False for deactivated accounts */\n\tisActive: boolean;\n\t/** True while the user has to set a new password */\n\tmustChangePw: boolean;\n\t/** Preferred language */\n\tlocale: string;\n\t/** IANA time zone of the user */\n\ttimezone: string;\n\t/** Hash of the personal kiosk PIN, `null` when none is set */\n\tpinHash: string | null;\n\t/** Picture of the employee as a data URL, `null` when none is stored */\n\tavatar: string | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n\t/** Instant of the last change, UTC epoch seconds */\n\tupdatedAt: number;\n\t/** Instant of the last successful login, `null` if never logged in */\n\tlastLoginAt: number | null;\n}\n\n/** Employment parameters of a user (`work_profiles`). */\nexport interface WorkProfileRecord extends WorkProfile {\n\t/** Owner of the profile */\n\tuserId: number;\n\t/** Employment level in percent */\n\tpercent: number;\n\t/** Contracted hours per week at 100 % */\n\tweeklyHours: number;\n\t/** Active working days, `0;1;\u2026` for Sunday\u2026Saturday */\n\tworkdays: string;\n\t/** First day of the employment (UTC epoch seconds) */\n\tstartDate: number | null;\n\t/** Last day of the employment (UTC epoch seconds) */\n\tendDate: number | null;\n\t/** Overtime taken over from the previous year, in minutes */\n\tovertimeCarryover: number;\n\t/** Annual \u201CVorholzeit\u201D in minutes */\n\tvorholzeitPerYear: number;\n\t/** Vacation days taken over from the previous year */\n\tvacationCarryover: number;\n\t/** Annual vacation entitlement in days */\n\tvacationPerYear: number;\n\t/** Overtime model used for the reports */\n\tovertimeModel: OvertimeModel;\n\t/** Country specific holiday flags (JSON), `null` = instance default */\n\tholidayFlags: string | null;\n\t/** Minutes of the break per day that are paid (0 = the break is not paid at all) */\n\tpausePaidMinutes: number;\n}\n\n/** Input for creating a user. */\nexport interface CreateUserInput {\n\t/** Unique login name */\n\tlogin: string;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** Password hash (already hashed by the caller) */\n\tpasswordHash?: string;\n\t/** E-mail address */\n\temail?: string | null;\n\t/** Preferred language, default `de-DE` */\n\tlocale?: string;\n\t/** IANA time zone, default `Europe/Berlin` */\n\ttimezone?: string;\n\t/** RFID card id */\n\trfidCard?: string | null;\n\t/** Forces a password change on the next login */\n\tmustChangePw?: boolean;\n\t/** Roles to assign (all permissions are granted by the seed roles) */\n\troleKeys?: string[];\n\t/** Id of the user who is an additional employee of the new account (self service) */\n\tactorId?: number | null;\n\t/** Client IP address */\n\tactorIp?: string | null;\n\t/** Instant of creation, defaults to now */\n\tnow?: number;\n}\n\n/** Input for changing a user. */\nexport interface UpdateUserInput {\n\t/** Id of the changed user */\n\tid: number;\n\t/** Fields to change */\n\tpatch: {\n\t\tdisplayName?: string;\n\t\temail?: string | null;\n\t\tlocale?: string;\n\t\ttimezone?: string;\n\t\trfidCard?: string | null;\n\t\tisActive?: boolean;\n\t\tmustChangePw?: boolean;\n\t\t/** Password hash (already hashed by the caller) */\n\t\tpasswordHash?: string;\n\t};\n\t/** Reason of the change (recorded in the audit trail) */\n\treason?: string | null;\n\t/** Who performs the change */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Instant of the change, defaults to now */\n\tnow?: number;\n}\n\n/** Thrown when a login name is already taken. */\nexport class LoginExistsError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param login - login name that is already taken\n\t */\n\tconstructor(public readonly login: string) {\n\t\tsuper(`login \"${login}\" already exists`);\n\t\tthis.name = \"LoginExistsError\";\n\t}\n}\n\n/** Thrown when a role key is not known. */\nexport class UnknownRoleError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param roleKey - unknown role key\n\t */\n\tconstructor(public readonly roleKey: string) {\n\t\tsuper(`unknown role \"${roleKey}\"`);\n\t\tthis.name = \"UnknownRoleError\";\n\t}\n}\n\n/** User storage operations. */\nexport interface UsersRepository {\n\t/** Creates a user including a default work profile */\n\tcreate(input: CreateUserInput): UserRecord;\n\t/** Reads a user by id */\n\tfindById(id: number): UserRecord | null;\n\t/** Reads a user by login (case insensitive) */\n\tfindByLogin(login: string): UserRecord | null;\n\t/** Reads a user by the RFID card of the badge terminal */\n\tfindByRfidCard(card: string): UserRecord | null;\n\t/** Stores or clears the personal kiosk PIN (never written to the audit trail) */\n\tsetPin(input: {\n\t\tuserId: number;\n\t\tpinHash: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): void;\n\t/** Stores or clears the picture of an employee (the image itself never reaches the audit trail) */\n\tsetAvatar(input: {\n\t\t/** Id of the employee */\n\t\tuserId: number;\n\t\t/** Data URL of the picture, `null` clears it */\n\t\tavatar: string | null;\n\t\t/** Who changes the picture */\n\t\tactorId: number;\n\t\t/** Client IP address of the actor */\n\t\tactorIp?: string | null;\n\t\t/** Instant of the change, defaults to now */\n\t\tnow?: number;\n\t}): void;\n\t/** All users, optionally including the deactivated ones */\n\tlist(options?: { includeInactive?: boolean }): UserRecord[];\n\t/** Changes a user and audits the changed fields */\n\tupdate(input: UpdateUserInput): UserRecord;\n\t/** Role keys of a user (sorted) */\n\troles(userId: number): string[];\n\t/** All roles with their permission keys, sorted by key */\n\troleCatalog(): { key: string; name: string; permissions: string[] }[];\n\t/** Replaces the roles of a user */\n\tsetRoles(input: {\n\t\tuserId: number;\n\t\troleKeys: string[];\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): string[];\n\t/** Effective permission keys of a user (sorted) */\n\tpermissions(userId: number): string[];\n\t/** Checks whether a user holds all given permissions */\n\thasPermissions(userId: number, required: string[]): boolean;\n\t/** Work profile of a user, `null` when none is stored */\n\tgetWorkProfile(userId: number): WorkProfileRecord | null;\n\t/** Creates or updates the work profile */\n\tsaveWorkProfile(input: {\n\t\tuserId: number;\n\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): WorkProfileRecord;\n\t/** Stores the instant of the last successful login */\n\trecordLogin(userId: number, now?: number): void;\n}\n\ninterface UserRow {\n\tid: number;\n\tlogin: string;\n\tpassword_hash: string;\n\tdisplay_name: string;\n\temail: string | null;\n\trfid_card: string | null;\n\tis_active: number;\n\tmust_change_pw: number;\n\tlocale: string;\n\ttimezone: string;\n\tpin_hash: string | null;\n\tavatar: string | null;\n\tcreated_at: number;\n\tupdated_at: number;\n\tlast_login_at: number | null;\n}\n\ninterface WorkProfileRow {\n\tuser_id: number;\n\tpercent: number;\n\tweekly_hours: number;\n\tworkdays: string;\n\tstart_date: number | null;\n\tend_date: number | null;\n\tovertime_carryover: number;\n\tvorholzeit_per_year: number;\n\tvacation_carryover: number;\n\tvacation_per_year: number;\n\tovertime_model: OvertimeModel;\n\tholiday_flags: string | null;\n\tpause_paid_minutes: number;\n}\n\n/**\n * Maps a database row to a user record.\n *\n * @param row - raw database row\n * @returns user record\n */\nexport function mapUserRow(row: UserRow): UserRecord {\n\treturn {\n\t\tid: row.id,\n\t\tlogin: row.login,\n\t\tpasswordHash: row.password_hash,\n\t\tdisplayName: row.display_name,\n\t\temail: row.email,\n\t\trfidCard: row.rfid_card,\n\t\tisActive: row.is_active !== 0,\n\t\tmustChangePw: row.must_change_pw !== 0,\n\t\tlocale: row.locale,\n\t\ttimezone: row.timezone,\n\t\tpinHash: row.pin_hash,\n\t\tavatar: row.avatar,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t\tlastLoginAt: row.last_login_at,\n\t};\n}\n\n/**\n * Maps a database row to a work profile record.\n *\n * @param row - raw database row\n * @returns work profile record\n */\nexport function mapWorkProfileRow(row: WorkProfileRow): WorkProfileRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tpercent: row.percent,\n\t\tweeklyHours: row.weekly_hours,\n\t\tworkdays: row.workdays,\n\t\tstartDate: row.start_date,\n\t\tendDate: row.end_date,\n\t\tovertimeCarryover: row.overtime_carryover,\n\t\tvorholzeitPerYear: row.vorholzeit_per_year,\n\t\tvacationCarryover: row.vacation_carryover,\n\t\tvacationPerYear: row.vacation_per_year,\n\t\tovertimeModel: row.overtime_model,\n\t\tholidayFlags: row.holiday_flags,\n\t\tpausePaidMinutes: row.pause_paid_minutes,\n\t};\n}\n\nconst USER_COLUMNS = `id, login, password_hash, display_name, email, rfid_card, is_active,\n\\tmust_change_pw, locale, timezone, pin_hash, avatar, created_at, updated_at, last_login_at`;\n\nconst PROFILE_COLUMNS = `user_id, percent, weekly_hours, workdays, start_date, end_date, overtime_carryover,\n\\tvorholzeit_per_year, vacation_carryover, vacation_per_year, overtime_model, holiday_flags, pause_paid_minutes`;\n\n/** Field names of a user that are compared for the audit trail (the password is reported separately). */\nconst AUDITED_USER_FIELDS: (keyof UserRecord)[] = [\n\t\"login\",\n\t\"displayName\",\n\t\"email\",\n\t\"rfidCard\",\n\t\"isActive\",\n\t\"mustChangePw\",\n\t\"locale\",\n\t\"timezone\",\n];\n\n/** Field names of a work profile that are compared for the audit trail. */\n/** Field names of a work profile that are compared for the audit trail. */\nconst AUDITED_PROFILE_FIELDS: (keyof WorkProfileRecord)[] = [\n\t\"percent\",\n\t\"weeklyHours\",\n\t\"workdays\",\n\t\"startDate\",\n\t\"endDate\",\n\t\"overtimeCarryover\",\n\t\"vorholzeitPerYear\",\n\t\"vacationCarryover\",\n\t\"vacationPerYear\",\n\t\"overtimeModel\",\n\t\"holidayFlags\",\n\t\"pausePaidMinutes\",\n];\n\n/**\n * Creates the user repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createUsersRepository(db: Db): UsersRepository {\n\tconst selectById = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE id = ?`);\n\tconst selectByLogin = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE login = ? COLLATE NOCASE`);\n\tconst selectByRfid = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE rfid_card = ? COLLATE NOCASE LIMIT 1`);\n\tconst setPinHash = db.prepare(\"UPDATE users SET pin_hash = ?, updated_at = ? WHERE id = ?\");\n\tconst setAvatarData = db.prepare(\"UPDATE users SET avatar = ?, updated_at = ? WHERE id = ?\");\n\tconst selectAll = db.prepare(`SELECT ${USER_COLUMNS} FROM users ORDER BY display_name COLLATE NOCASE, id`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${USER_COLUMNS} FROM users WHERE is_active = 1 ORDER BY display_name COLLATE NOCASE, id`,\n\t);\n\tconst insertUser = db.prepare(\n\t\t`INSERT INTO users\n\t\t (login, password_hash, display_name, email, rfid_card, is_active, must_change_pw,\n\t\t locale, timezone, created_at, updated_at)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateUser = db.prepare(\n\t\t`UPDATE users SET display_name = ?, email = ?, locale = ?, timezone = ?, rfid_card = ?,\n\t\t is_active = ?, must_change_pw = ?, password_hash = ?, updated_at = ?\n\t\t WHERE id = ?`,\n\t);\n\tconst updateLastLogin = db.prepare(\"UPDATE users SET last_login_at = ? WHERE id = ?\");\n\tconst insertDefaultProfile = db.prepare(\"INSERT OR IGNORE INTO work_profiles (user_id) VALUES (?)\");\n\tconst selectProfile = db.prepare(`SELECT ${PROFILE_COLUMNS} FROM work_profiles WHERE user_id = ?`);\n\tconst upsertProfile = db.prepare(\n\t\t`INSERT INTO work_profiles (${PROFILE_COLUMNS})\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id) DO UPDATE SET\n\t\t percent = excluded.percent,\n\t\t weekly_hours = excluded.weekly_hours,\n\t\t workdays = excluded.workdays,\n\t\t start_date = excluded.start_date,\n\t\t end_date = excluded.end_date,\n\t\t overtime_carryover = excluded.overtime_carryover,\n\t\t vorholzeit_per_year = excluded.vorholzeit_per_year,\n\t\t vacation_carryover = excluded.vacation_carryover,\n\t\t vacation_per_year = excluded.vacation_per_year,\n\t\t overtime_model = excluded.overtime_model,\n\t\t holiday_flags = excluded.holiday_flags,\n\t\t pause_paid_minutes = excluded.pause_paid_minutes`,\n\t);\n\tconst selectRoles = db.prepare(\n\t\t`SELECT r.key FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? ORDER BY r.key`,\n\t);\n\tconst selectRoleId = db.prepare(\"SELECT id FROM roles WHERE key = ?\");\n\tconst deleteRoles = db.prepare(\"DELETE FROM user_roles WHERE user_id = ?\");\n\tconst insertRole = db.prepare(\"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)\");\n\tconst selectPermissions = db.prepare(\n\t\t`SELECT DISTINCT p.key FROM user_roles ur\n\t\t JOIN role_permissions rp ON rp.role_id = ur.role_id\n\t\t JOIN permissions p ON p.id = rp.permission_id\n\t\t WHERE ur.user_id = ? ORDER BY p.key`,\n\t);\n\tconst selectRoleCatalog = db.prepare(\n\t\t`SELECT r.key AS key, r.name AS name, p.key AS permission\n\t\t FROM roles r\n\t\t LEFT JOIN role_permissions rp ON rp.role_id = r.id\n\t\t LEFT JOIN permissions p ON p.id = rp.permission_id\n\t\t ORDER BY r.key, p.key`,\n\t);\n\n\tconst read = (id: number): UserRecord | null => {\n\t\tconst row = selectById.get(id) as UserRow | undefined;\n\t\treturn row ? mapUserRow(row) : null;\n\t};\n\n\tconst readProfile = (userId: number): WorkProfileRecord | null => {\n\t\tconst row = selectProfile.get(userId) as WorkProfileRow | undefined;\n\t\treturn row ? mapWorkProfileRow(row) : null;\n\t};\n\n\t/**\n\t * Defaults of a fresh work profile (identical to the column defaults of the schema).\n\t *\n\t * @param userId - owner of the profile\n\t */\n\tconst defaultProfile = (userId: number): WorkProfileRecord => ({\n\t\tuserId,\n\t\tpercent: 100,\n\t\tweeklyHours: 42.5,\n\t\tworkdays: \"0;1;1;1;1;1;0\",\n\t\tstartDate: null,\n\t\tendDate: null,\n\t\tovertimeCarryover: 0,\n\t\tvorholzeitPerYear: 0,\n\t\tvacationCarryover: 0,\n\t\tvacationPerYear: 0,\n\t\tovertimeModel: \"monthly\",\n\t\tholidayFlags: null,\n\t\tpausePaidMinutes: 0,\n\t});\n\n\tconst resolveRoleIds = (roleKeys: string[]): number[] =>\n\t\troleKeys.map(key => {\n\t\t\tconst row = selectRoleId.get(key) as { id: number } | undefined;\n\t\t\tif (!row) {\n\t\t\t\tthrow new UnknownRoleError(key);\n\t\t\t}\n\t\t\treturn row.id;\n\t\t});\n\n\tconst writeRoles = (userId: number, roleIds: number[]): void => {\n\t\tdeleteRoles.run(userId);\n\t\tfor (const roleId of roleIds) {\n\t\t\tinsertRole.run(userId, roleId);\n\t\t}\n\t};\n\n\treturn {\n\t\tcreate(input: CreateUserInput): UserRecord {\n\t\t\tconst login = input.login.trim();\n\t\t\tif (!login) {\n\t\t\t\tthrow new ValidationError(\"login must not be empty\");\n\t\t\t}\n\t\t\tconst existing = selectByLogin.get(login) as UserRow | undefined;\n\t\t\tif (existing) {\n\t\t\t\tthrow new LoginExistsError(login);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst roleKeys = input.roleKeys ?? [];\n\t\t\tconst roleIds = resolveRoleIds(roleKeys);\n\n\t\t\tlet userId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertUser.run(\n\t\t\t\t\tlogin,\n\t\t\t\t\tinput.passwordHash ?? \"\",\n\t\t\t\t\tinput.displayName,\n\t\t\t\t\tinput.email ?? null,\n\t\t\t\t\tinput.rfidCard ?? null,\n\t\t\t\t\t1,\n\t\t\t\t\tinput.mustChangePw === true ? 1 : 0,\n\t\t\t\t\tinput.locale ?? \"de-DE\",\n\t\t\t\t\tinput.timezone ?? \"Europe/Berlin\",\n\t\t\t\t\tnow,\n\t\t\t\t\tnow,\n\t\t\t\t);\n\t\t\t\tuserId = Number(result.lastInsertRowid);\n\t\t\t\tinsertDefaultProfile.run(userId);\n\t\t\t\twriteRoles(userId, roleIds);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId ?? null,\n\t\t\t\t\taction: \"user.create\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: userId,\n\t\t\t\t\tdetail: { login, displayName: input.displayName, roles: roleKeys.slice().sort() },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst created = read(userId);\n\t\t\tif (!created) {\n\t\t\t\tthrow new Error(`user ${userId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn created;\n\t\t},\n\n\t\tfindById: read,\n\n\t\tfindByLogin(login: string): UserRecord | null {\n\t\t\tconst row = selectByLogin.get(login.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tfindByRfidCard(card: string): UserRecord | null {\n\t\t\tconst row = selectByRfid.get(card.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tsetPin(input: {\n\t\t\tuserId: number;\n\t\t\tpinHash: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetPinHash.run(input.pinHash, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.pin\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the hash itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.pinHash === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tsetAvatar(input: {\n\t\t\tuserId: number;\n\t\t\tavatar: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetAvatarData.run(input.avatar, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.avatar\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the picture itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.avatar === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): UserRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as UserRow[];\n\t\t\treturn rows.map(mapUserRow);\n\t\t},\n\n\t\tupdate(input: UpdateUserInput): UserRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`user ${input.id} not found`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst next: UserRecord = {\n\t\t\t\t...current,\n\t\t\t\tdisplayName: input.patch.displayName ?? current.displayName,\n\t\t\t\temail: input.patch.email === undefined ? current.email : input.patch.email,\n\t\t\t\tlocale: input.patch.locale ?? current.locale,\n\t\t\t\ttimezone: input.patch.timezone ?? current.timezone,\n\t\t\t\trfidCard: input.patch.rfidCard === undefined ? current.rfidCard : input.patch.rfidCard,\n\t\t\t\tisActive: input.patch.isActive ?? current.isActive,\n\t\t\t\tmustChangePw: input.patch.mustChangePw ?? current.mustChangePw,\n\t\t\t\tpasswordHash: input.patch.passwordHash ?? current.passwordHash,\n\t\t\t};\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tcurrent as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_USER_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tconst passwordChanged = next.passwordHash !== current.passwordHash;\n\t\t\tif (Object.keys(changes).length === 0 && !passwordChanged) {\n\t\t\t\t// nothing to do, keep `updated_at` untouched\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst action =\n\t\t\t\t!current.isActive && next.isActive\n\t\t\t\t\t? \"user.activate\"\n\t\t\t\t\t: current.isActive && !next.isActive\n\t\t\t\t\t\t? \"user.deactivate\"\n\t\t\t\t\t\t: \"user.update\";\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupdateUser.run(\n\t\t\t\t\tnext.displayName,\n\t\t\t\t\tnext.email,\n\t\t\t\t\tnext.locale,\n\t\t\t\t\tnext.timezone,\n\t\t\t\t\tnext.rfidCard,\n\t\t\t\t\tnext.isActive ? 1 : 0,\n\t\t\t\t\tnext.mustChangePw ? 1 : 0,\n\t\t\t\t\tnext.passwordHash,\n\t\t\t\t\tnow,\n\t\t\t\t\tinput.id,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction,\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\t// never write the password hash into the audit trail\n\t\t\t\t\tdetail: { changes, passwordChanged, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst updated = read(input.id);\n\t\t\tif (!updated) {\n\t\t\t\tthrow new Error(`user ${input.id} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\troles(userId: number): string[] {\n\t\t\treturn (selectRoles.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\troleCatalog(): { key: string; name: string; permissions: string[] }[] {\n\t\t\tconst rows = selectRoleCatalog.all() as { key: string; name: string; permission: string | null }[];\n\t\t\tconst catalog = new Map<string, { key: string; name: string; permissions: string[] }>();\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst entry = catalog.get(row.key) ?? { key: row.key, name: row.name, permissions: [] };\n\t\t\t\tif (row.permission) {\n\t\t\t\t\tentry.permissions.push(row.permission);\n\t\t\t\t}\n\t\t\t\tcatalog.set(row.key, entry);\n\t\t\t}\n\t\t\treturn [...catalog.values()];\n\t\t},\n\n\t\tsetRoles(input: {\n\t\t\tuserId: number;\n\t\t\troleKeys: string[];\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): string[] {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\t// resolve (and validate) all keys before the first write\n\t\t\tconst roleIds = resolveRoleIds(input.roleKeys);\n\t\t\tconst before = this.roles(input.userId);\n\t\t\tconst after = input.roleKeys.slice().sort();\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\twriteRoles(input.userId, roleIds);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.roles\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { old: before, new: after },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn this.roles(input.userId);\n\t\t},\n\n\t\tpermissions(userId: number): string[] {\n\t\t\treturn (selectPermissions.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\thasPermissions(userId: number, required: string[]): boolean {\n\t\t\tconst granted = new Set(this.permissions(userId));\n\t\t\treturn required.every(permission => granted.has(permission));\n\t\t},\n\n\t\tgetWorkProfile: readProfile,\n\n\t\tsaveWorkProfile(input: {\n\t\t\tuserId: number;\n\t\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): WorkProfileRecord {\n\t\t\tconst current = readProfile(input.userId);\n\t\t\tconst base = current ?? defaultProfile(input.userId);\n\t\t\tconst next: WorkProfileRecord = { ...base, ...input.profile, userId: input.userId };\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tbase as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_PROFILE_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tif (current && Object.keys(changes).length === 0) {\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupsertProfile.run(\n\t\t\t\t\tnext.userId,\n\t\t\t\t\tnext.percent,\n\t\t\t\t\tnext.weeklyHours,\n\t\t\t\t\tnext.workdays,\n\t\t\t\t\tnext.startDate,\n\t\t\t\t\tnext.endDate,\n\t\t\t\t\tnext.overtimeCarryover,\n\t\t\t\t\tnext.vorholzeitPerYear,\n\t\t\t\t\tnext.vacationCarryover,\n\t\t\t\t\tnext.vacationPerYear,\n\t\t\t\t\tnext.overtimeModel,\n\t\t\t\t\tnext.holidayFlags,\n\t\t\t\t\tnext.pausePaidMinutes,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.profile\",\n\t\t\t\t\tentity: \"work_profile\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { created: current === null, changes },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = readProfile(input.userId);\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`work profile of user ${input.userId} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn saved;\n\t\t},\n\n\t\trecordLogin(userId: number, now?: number): void {\n\t\t\tupdateLastLogin.run(now ?? Math.floor(Date.now() / 1000), userId);\n\t\t},\n\t};\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUA,oBAA+C;AAC/C,mBAA0C;
|
|
4
|
+
"sourcesContent": ["/**\n * User repository (`users`, `user_roles`, `work_profiles`).\n *\n * Every write records an audit entry in the same transaction; passwords and password hashes never end up\n * in the audit trail, only the fact that they were changed.\n */\n\nimport type { Db } from \"../database\";\nimport type { OvertimeModel } from \"../../domain/calculation\";\nimport type { WorkProfile } from \"../../domain/target\";\nimport { NotFoundError, ValidationError } from \"../../errors\";\nimport { writeAuditLog, diffFields } from \"./audit\";\n\n/** A user as stored in the database. */\nexport interface UserRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Unique login name */\n\tlogin: string;\n\t/** Hash of the password (never leaves the server) */\n\tpasswordHash: string;\n\t/** Name shown in the UI */\n\tdisplayName: string;\n\t/** E-mail address */\n\temail: string | null;\n\t/** RFID card id */\n\trfidCard: string | null;\n\t/** False for deactivated accounts */\n\tisActive: boolean;\n\t/** True while the user has to set a new password */\n\tmustChangePw: boolean;\n\t/** Preferred language */\n\tlocale: string;\n\t/** IANA time zone of the user */\n\ttimezone: string;\n\t/** Hash of the personal kiosk PIN, `null` when none is set */\n\tpinHash: string | null;\n\t/** Picture of the employee as a data URL, `null` when none is stored */\n\tavatar: string | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n\t/** Instant of the last change, UTC epoch seconds */\n\tupdatedAt: number;\n\t/** Instant of the last successful login, `null` if never logged in */\n\tlastLoginAt: number | null;\n}\n\n/** Employment parameters of a user (`work_profiles`). */\nexport interface WorkProfileRecord extends WorkProfile {\n\t/** Owner of the profile */\n\tuserId: number;\n\t/** Employment level in percent */\n\tpercent: number;\n\t/** Contracted hours per week at 100 % */\n\tweeklyHours: number;\n\t/** Active working days, `0;1;\u2026` for Sunday\u2026Saturday */\n\tworkdays: string;\n\t/** First day of the employment (UTC epoch seconds) */\n\tstartDate: number | null;\n\t/** Last day of the employment (UTC epoch seconds) */\n\tendDate: number | null;\n\t/** Overtime taken over from the previous year, in minutes */\n\tovertimeCarryover: number;\n\t/** Annual \u201CVorholzeit\u201D in minutes */\n\tvorholzeitPerYear: number;\n\t/** Vacation days taken over from the previous year */\n\tvacationCarryover: number;\n\t/** Annual vacation entitlement in days */\n\tvacationPerYear: number;\n\t/** Overtime model used for the reports */\n\tovertimeModel: OvertimeModel;\n\t/** Country specific holiday flags (JSON), `null` = instance default */\n\tholidayFlags: string | null;\n\t/** Minutes of the break per day that are paid (0 = the break is not paid at all) */\n\tpausePaidMinutes: number;\n\t/** True when the presence card shows the working time of today for this employee */\n\tshowWorkedTime: boolean;\n}\n\n/** Input for creating a user. */\nexport interface CreateUserInput {\n\t/** Unique login name */\n\tlogin: string;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** Password hash (already hashed by the caller) */\n\tpasswordHash?: string;\n\t/** E-mail address */\n\temail?: string | null;\n\t/** Preferred language, default `de-DE` */\n\tlocale?: string;\n\t/** IANA time zone, default `Europe/Berlin` */\n\ttimezone?: string;\n\t/** RFID card id */\n\trfidCard?: string | null;\n\t/** Forces a password change on the next login */\n\tmustChangePw?: boolean;\n\t/** Roles to assign (all permissions are granted by the seed roles) */\n\troleKeys?: string[];\n\t/** Id of the user who is an additional employee of the new account (self service) */\n\tactorId?: number | null;\n\t/** Client IP address */\n\tactorIp?: string | null;\n\t/** Instant of creation, defaults to now */\n\tnow?: number;\n}\n\n/** Input for changing a user. */\nexport interface UpdateUserInput {\n\t/** Id of the changed user */\n\tid: number;\n\t/** Fields to change */\n\tpatch: {\n\t\tdisplayName?: string;\n\t\temail?: string | null;\n\t\tlocale?: string;\n\t\ttimezone?: string;\n\t\trfidCard?: string | null;\n\t\tisActive?: boolean;\n\t\tmustChangePw?: boolean;\n\t\t/** Password hash (already hashed by the caller) */\n\t\tpasswordHash?: string;\n\t};\n\t/** Reason of the change (recorded in the audit trail) */\n\treason?: string | null;\n\t/** Who performs the change */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Instant of the change, defaults to now */\n\tnow?: number;\n}\n\n/** Thrown when a login name is already taken. */\nexport class LoginExistsError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param login - login name that is already taken\n\t */\n\tconstructor(public readonly login: string) {\n\t\tsuper(`login \"${login}\" already exists`);\n\t\tthis.name = \"LoginExistsError\";\n\t}\n}\n\n/** Thrown when a role key is not known. */\nexport class UnknownRoleError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param roleKey - unknown role key\n\t */\n\tconstructor(public readonly roleKey: string) {\n\t\tsuper(`unknown role \"${roleKey}\"`);\n\t\tthis.name = \"UnknownRoleError\";\n\t}\n}\n\n/** User storage operations. */\nexport interface UsersRepository {\n\t/** Creates a user including a default work profile */\n\tcreate(input: CreateUserInput): UserRecord;\n\t/** Reads a user by id */\n\tfindById(id: number): UserRecord | null;\n\t/** Reads a user by login (case insensitive) */\n\tfindByLogin(login: string): UserRecord | null;\n\t/** Reads a user by the RFID card of the badge terminal */\n\tfindByRfidCard(card: string): UserRecord | null;\n\t/** Stores or clears the personal kiosk PIN (never written to the audit trail) */\n\tsetPin(input: {\n\t\tuserId: number;\n\t\tpinHash: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): void;\n\t/** Stores or clears the picture of an employee (the image itself never reaches the audit trail) */\n\tsetAvatar(input: {\n\t\t/** Id of the employee */\n\t\tuserId: number;\n\t\t/** Data URL of the picture, `null` clears it */\n\t\tavatar: string | null;\n\t\t/** Who changes the picture */\n\t\tactorId: number;\n\t\t/** Client IP address of the actor */\n\t\tactorIp?: string | null;\n\t\t/** Instant of the change, defaults to now */\n\t\tnow?: number;\n\t}): void;\n\t/** All users, optionally including the deactivated ones */\n\tlist(options?: { includeInactive?: boolean }): UserRecord[];\n\t/** Changes a user and audits the changed fields */\n\tupdate(input: UpdateUserInput): UserRecord;\n\t/** Role keys of a user (sorted) */\n\troles(userId: number): string[];\n\t/** All roles with their permission keys, sorted by key */\n\troleCatalog(): { key: string; name: string; permissions: string[] }[];\n\t/** Replaces the roles of a user */\n\tsetRoles(input: {\n\t\tuserId: number;\n\t\troleKeys: string[];\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): string[];\n\t/** Effective permission keys of a user (sorted) */\n\tpermissions(userId: number): string[];\n\t/** Checks whether a user holds all given permissions */\n\thasPermissions(userId: number, required: string[]): boolean;\n\t/** Work profile of a user, `null` when none is stored */\n\tgetWorkProfile(userId: number): WorkProfileRecord | null;\n\t/** Creates or updates the work profile */\n\tsaveWorkProfile(input: {\n\t\tuserId: number;\n\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): WorkProfileRecord;\n\t/** Stores the instant of the last successful login */\n\trecordLogin(userId: number, now?: number): void;\n}\n\ninterface UserRow {\n\tid: number;\n\tlogin: string;\n\tpassword_hash: string;\n\tdisplay_name: string;\n\temail: string | null;\n\trfid_card: string | null;\n\tis_active: number;\n\tmust_change_pw: number;\n\tlocale: string;\n\ttimezone: string;\n\tpin_hash: string | null;\n\tavatar: string | null;\n\tcreated_at: number;\n\tupdated_at: number;\n\tlast_login_at: number | null;\n}\n\ninterface WorkProfileRow {\n\tuser_id: number;\n\tpercent: number;\n\tweekly_hours: number;\n\tworkdays: string;\n\tstart_date: number | null;\n\tend_date: number | null;\n\tovertime_carryover: number;\n\tvorholzeit_per_year: number;\n\tvacation_carryover: number;\n\tvacation_per_year: number;\n\tovertime_model: OvertimeModel;\n\tholiday_flags: string | null;\n\tpause_paid_minutes: number;\n\tshow_worked_time: number;\n}\n\n/**\n * Maps a database row to a user record.\n *\n * @param row - raw database row\n * @returns user record\n */\nexport function mapUserRow(row: UserRow): UserRecord {\n\treturn {\n\t\tid: row.id,\n\t\tlogin: row.login,\n\t\tpasswordHash: row.password_hash,\n\t\tdisplayName: row.display_name,\n\t\temail: row.email,\n\t\trfidCard: row.rfid_card,\n\t\tisActive: row.is_active !== 0,\n\t\tmustChangePw: row.must_change_pw !== 0,\n\t\tlocale: row.locale,\n\t\ttimezone: row.timezone,\n\t\tpinHash: row.pin_hash,\n\t\tavatar: row.avatar,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t\tlastLoginAt: row.last_login_at,\n\t};\n}\n\n/**\n * Maps a database row to a work profile record.\n *\n * @param row - raw database row\n * @returns work profile record\n */\nexport function mapWorkProfileRow(row: WorkProfileRow): WorkProfileRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tpercent: row.percent,\n\t\tweeklyHours: row.weekly_hours,\n\t\tworkdays: row.workdays,\n\t\tstartDate: row.start_date,\n\t\tendDate: row.end_date,\n\t\tovertimeCarryover: row.overtime_carryover,\n\t\tvorholzeitPerYear: row.vorholzeit_per_year,\n\t\tvacationCarryover: row.vacation_carryover,\n\t\tvacationPerYear: row.vacation_per_year,\n\t\tovertimeModel: row.overtime_model,\n\t\tholidayFlags: row.holiday_flags,\n\t\tpausePaidMinutes: row.pause_paid_minutes,\n\t\tshowWorkedTime: row.show_worked_time !== 0,\n\t};\n}\n\nconst USER_COLUMNS = `id, login, password_hash, display_name, email, rfid_card, is_active,\n\\tmust_change_pw, locale, timezone, pin_hash, avatar, created_at, updated_at, last_login_at`;\n\nconst PROFILE_COLUMNS = `user_id, percent, weekly_hours, workdays, start_date, end_date, overtime_carryover,\n\\tvorholzeit_per_year, vacation_carryover, vacation_per_year, overtime_model, holiday_flags, pause_paid_minutes,\n\tshow_worked_time`;\n\n/** Field names of a user that are compared for the audit trail (the password is reported separately). */\nconst AUDITED_USER_FIELDS: (keyof UserRecord)[] = [\n\t\"login\",\n\t\"displayName\",\n\t\"email\",\n\t\"rfidCard\",\n\t\"isActive\",\n\t\"mustChangePw\",\n\t\"locale\",\n\t\"timezone\",\n];\n\n/** Field names of a work profile that are compared for the audit trail. */\n/** Field names of a work profile that are compared for the audit trail. */\nconst AUDITED_PROFILE_FIELDS: (keyof WorkProfileRecord)[] = [\n\t\"percent\",\n\t\"weeklyHours\",\n\t\"workdays\",\n\t\"startDate\",\n\t\"endDate\",\n\t\"overtimeCarryover\",\n\t\"vorholzeitPerYear\",\n\t\"vacationCarryover\",\n\t\"vacationPerYear\",\n\t\"overtimeModel\",\n\t\"holidayFlags\",\n\t\"pausePaidMinutes\",\n\t\"showWorkedTime\",\n];\n\n/**\n * Creates the user repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createUsersRepository(db: Db): UsersRepository {\n\tconst selectById = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE id = ?`);\n\tconst selectByLogin = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE login = ? COLLATE NOCASE`);\n\tconst selectByRfid = db.prepare(`SELECT ${USER_COLUMNS} FROM users WHERE rfid_card = ? COLLATE NOCASE LIMIT 1`);\n\tconst setPinHash = db.prepare(\"UPDATE users SET pin_hash = ?, updated_at = ? WHERE id = ?\");\n\tconst setAvatarData = db.prepare(\"UPDATE users SET avatar = ?, updated_at = ? WHERE id = ?\");\n\tconst selectAll = db.prepare(`SELECT ${USER_COLUMNS} FROM users ORDER BY display_name COLLATE NOCASE, id`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${USER_COLUMNS} FROM users WHERE is_active = 1 ORDER BY display_name COLLATE NOCASE, id`,\n\t);\n\tconst insertUser = db.prepare(\n\t\t`INSERT INTO users\n\t\t (login, password_hash, display_name, email, rfid_card, is_active, must_change_pw,\n\t\t locale, timezone, created_at, updated_at)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateUser = db.prepare(\n\t\t`UPDATE users SET display_name = ?, email = ?, locale = ?, timezone = ?, rfid_card = ?,\n\t\t is_active = ?, must_change_pw = ?, password_hash = ?, updated_at = ?\n\t\t WHERE id = ?`,\n\t);\n\tconst updateLastLogin = db.prepare(\"UPDATE users SET last_login_at = ? WHERE id = ?\");\n\tconst insertDefaultProfile = db.prepare(\"INSERT OR IGNORE INTO work_profiles (user_id) VALUES (?)\");\n\tconst selectProfile = db.prepare(`SELECT ${PROFILE_COLUMNS} FROM work_profiles WHERE user_id = ?`);\n\tconst upsertProfile = db.prepare(\n\t\t`INSERT INTO work_profiles (${PROFILE_COLUMNS})\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id) DO UPDATE SET\n\t\t percent = excluded.percent,\n\t\t weekly_hours = excluded.weekly_hours,\n\t\t workdays = excluded.workdays,\n\t\t start_date = excluded.start_date,\n\t\t end_date = excluded.end_date,\n\t\t overtime_carryover = excluded.overtime_carryover,\n\t\t vorholzeit_per_year = excluded.vorholzeit_per_year,\n\t\t vacation_carryover = excluded.vacation_carryover,\n\t\t vacation_per_year = excluded.vacation_per_year,\n\t\t overtime_model = excluded.overtime_model,\n\t\t holiday_flags = excluded.holiday_flags,\n\t\t pause_paid_minutes = excluded.pause_paid_minutes,\n\t\t show_worked_time = excluded.show_worked_time`,\n\t);\n\tconst selectRoles = db.prepare(\n\t\t`SELECT r.key FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? ORDER BY r.key`,\n\t);\n\tconst selectRoleId = db.prepare(\"SELECT id FROM roles WHERE key = ?\");\n\tconst deleteRoles = db.prepare(\"DELETE FROM user_roles WHERE user_id = ?\");\n\tconst insertRole = db.prepare(\"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)\");\n\tconst selectPermissions = db.prepare(\n\t\t`SELECT DISTINCT p.key FROM user_roles ur\n\t\t JOIN role_permissions rp ON rp.role_id = ur.role_id\n\t\t JOIN permissions p ON p.id = rp.permission_id\n\t\t WHERE ur.user_id = ? ORDER BY p.key`,\n\t);\n\tconst selectRoleCatalog = db.prepare(\n\t\t`SELECT r.key AS key, r.name AS name, p.key AS permission\n\t\t FROM roles r\n\t\t LEFT JOIN role_permissions rp ON rp.role_id = r.id\n\t\t LEFT JOIN permissions p ON p.id = rp.permission_id\n\t\t ORDER BY r.key, p.key`,\n\t);\n\n\tconst read = (id: number): UserRecord | null => {\n\t\tconst row = selectById.get(id) as UserRow | undefined;\n\t\treturn row ? mapUserRow(row) : null;\n\t};\n\n\tconst readProfile = (userId: number): WorkProfileRecord | null => {\n\t\tconst row = selectProfile.get(userId) as WorkProfileRow | undefined;\n\t\treturn row ? mapWorkProfileRow(row) : null;\n\t};\n\n\t/**\n\t * Defaults of a fresh work profile (identical to the column defaults of the schema).\n\t *\n\t * @param userId - owner of the profile\n\t */\n\tconst defaultProfile = (userId: number): WorkProfileRecord => ({\n\t\tuserId,\n\t\tpercent: 100,\n\t\tweeklyHours: 42.5,\n\t\tworkdays: \"0;1;1;1;1;1;0\",\n\t\tstartDate: null,\n\t\tendDate: null,\n\t\tovertimeCarryover: 0,\n\t\tvorholzeitPerYear: 0,\n\t\tvacationCarryover: 0,\n\t\tvacationPerYear: 0,\n\t\tovertimeModel: \"monthly\",\n\t\tholidayFlags: null,\n\t\tpausePaidMinutes: 0,\n\t\tshowWorkedTime: false,\n\t});\n\n\tconst resolveRoleIds = (roleKeys: string[]): number[] =>\n\t\troleKeys.map(key => {\n\t\t\tconst row = selectRoleId.get(key) as { id: number } | undefined;\n\t\t\tif (!row) {\n\t\t\t\tthrow new UnknownRoleError(key);\n\t\t\t}\n\t\t\treturn row.id;\n\t\t});\n\n\tconst writeRoles = (userId: number, roleIds: number[]): void => {\n\t\tdeleteRoles.run(userId);\n\t\tfor (const roleId of roleIds) {\n\t\t\tinsertRole.run(userId, roleId);\n\t\t}\n\t};\n\n\treturn {\n\t\tcreate(input: CreateUserInput): UserRecord {\n\t\t\tconst login = input.login.trim();\n\t\t\tif (!login) {\n\t\t\t\tthrow new ValidationError(\"login must not be empty\");\n\t\t\t}\n\t\t\tconst existing = selectByLogin.get(login) as UserRow | undefined;\n\t\t\tif (existing) {\n\t\t\t\tthrow new LoginExistsError(login);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst roleKeys = input.roleKeys ?? [];\n\t\t\tconst roleIds = resolveRoleIds(roleKeys);\n\n\t\t\tlet userId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertUser.run(\n\t\t\t\t\tlogin,\n\t\t\t\t\tinput.passwordHash ?? \"\",\n\t\t\t\t\tinput.displayName,\n\t\t\t\t\tinput.email ?? null,\n\t\t\t\t\tinput.rfidCard ?? null,\n\t\t\t\t\t1,\n\t\t\t\t\tinput.mustChangePw === true ? 1 : 0,\n\t\t\t\t\tinput.locale ?? \"de-DE\",\n\t\t\t\t\tinput.timezone ?? \"Europe/Berlin\",\n\t\t\t\t\tnow,\n\t\t\t\t\tnow,\n\t\t\t\t);\n\t\t\t\tuserId = Number(result.lastInsertRowid);\n\t\t\t\tinsertDefaultProfile.run(userId);\n\t\t\t\twriteRoles(userId, roleIds);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId ?? null,\n\t\t\t\t\taction: \"user.create\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: userId,\n\t\t\t\t\tdetail: { login, displayName: input.displayName, roles: roleKeys.slice().sort() },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst created = read(userId);\n\t\t\tif (!created) {\n\t\t\t\tthrow new Error(`user ${userId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn created;\n\t\t},\n\n\t\tfindById: read,\n\n\t\tfindByLogin(login: string): UserRecord | null {\n\t\t\tconst row = selectByLogin.get(login.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tfindByRfidCard(card: string): UserRecord | null {\n\t\t\tconst row = selectByRfid.get(card.trim()) as UserRow | undefined;\n\t\t\treturn row ? mapUserRow(row) : null;\n\t\t},\n\n\t\tsetPin(input: {\n\t\t\tuserId: number;\n\t\t\tpinHash: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetPinHash.run(input.pinHash, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.pin\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the hash itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.pinHash === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tsetAvatar(input: {\n\t\t\tuserId: number;\n\t\t\tavatar: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): void {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tsetAvatarData.run(input.avatar, now, input.userId);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.avatar\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\t// the picture itself never reaches the audit trail\n\t\t\t\t\tdetail: { cleared: input.avatar === null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): UserRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as UserRow[];\n\t\t\treturn rows.map(mapUserRow);\n\t\t},\n\n\t\tupdate(input: UpdateUserInput): UserRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`user ${input.id} not found`);\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst next: UserRecord = {\n\t\t\t\t...current,\n\t\t\t\tdisplayName: input.patch.displayName ?? current.displayName,\n\t\t\t\temail: input.patch.email === undefined ? current.email : input.patch.email,\n\t\t\t\tlocale: input.patch.locale ?? current.locale,\n\t\t\t\ttimezone: input.patch.timezone ?? current.timezone,\n\t\t\t\trfidCard: input.patch.rfidCard === undefined ? current.rfidCard : input.patch.rfidCard,\n\t\t\t\tisActive: input.patch.isActive ?? current.isActive,\n\t\t\t\tmustChangePw: input.patch.mustChangePw ?? current.mustChangePw,\n\t\t\t\tpasswordHash: input.patch.passwordHash ?? current.passwordHash,\n\t\t\t};\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tcurrent as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_USER_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tconst passwordChanged = next.passwordHash !== current.passwordHash;\n\t\t\tif (Object.keys(changes).length === 0 && !passwordChanged) {\n\t\t\t\t// nothing to do, keep `updated_at` untouched\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst action =\n\t\t\t\t!current.isActive && next.isActive\n\t\t\t\t\t? \"user.activate\"\n\t\t\t\t\t: current.isActive && !next.isActive\n\t\t\t\t\t\t? \"user.deactivate\"\n\t\t\t\t\t\t: \"user.update\";\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupdateUser.run(\n\t\t\t\t\tnext.displayName,\n\t\t\t\t\tnext.email,\n\t\t\t\t\tnext.locale,\n\t\t\t\t\tnext.timezone,\n\t\t\t\t\tnext.rfidCard,\n\t\t\t\t\tnext.isActive ? 1 : 0,\n\t\t\t\t\tnext.mustChangePw ? 1 : 0,\n\t\t\t\t\tnext.passwordHash,\n\t\t\t\t\tnow,\n\t\t\t\t\tinput.id,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction,\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\t// never write the password hash into the audit trail\n\t\t\t\t\tdetail: { changes, passwordChanged, reason: input.reason ?? null },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst updated = read(input.id);\n\t\t\tif (!updated) {\n\t\t\t\tthrow new Error(`user ${input.id} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\troles(userId: number): string[] {\n\t\t\treturn (selectRoles.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\troleCatalog(): { key: string; name: string; permissions: string[] }[] {\n\t\t\tconst rows = selectRoleCatalog.all() as { key: string; name: string; permission: string | null }[];\n\t\t\tconst catalog = new Map<string, { key: string; name: string; permissions: string[] }>();\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst entry = catalog.get(row.key) ?? { key: row.key, name: row.name, permissions: [] };\n\t\t\t\tif (row.permission) {\n\t\t\t\t\tentry.permissions.push(row.permission);\n\t\t\t\t}\n\t\t\t\tcatalog.set(row.key, entry);\n\t\t\t}\n\t\t\treturn [...catalog.values()];\n\t\t},\n\n\t\tsetRoles(input: {\n\t\t\tuserId: number;\n\t\t\troleKeys: string[];\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): string[] {\n\t\t\tif (!read(input.userId)) {\n\t\t\t\tthrow new NotFoundError(`user ${input.userId} not found`);\n\t\t\t}\n\t\t\t// resolve (and validate) all keys before the first write\n\t\t\tconst roleIds = resolveRoleIds(input.roleKeys);\n\t\t\tconst before = this.roles(input.userId);\n\t\t\tconst after = input.roleKeys.slice().sort();\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\twriteRoles(input.userId, roleIds);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.roles\",\n\t\t\t\t\tentity: \"user\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { old: before, new: after },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\treturn this.roles(input.userId);\n\t\t},\n\n\t\tpermissions(userId: number): string[] {\n\t\t\treturn (selectPermissions.all(userId) as { key: string }[]).map(row => row.key);\n\t\t},\n\n\t\thasPermissions(userId: number, required: string[]): boolean {\n\t\t\tconst granted = new Set(this.permissions(userId));\n\t\t\treturn required.every(permission => granted.has(permission));\n\t\t},\n\n\t\tgetWorkProfile: readProfile,\n\n\t\tsaveWorkProfile(input: {\n\t\t\tuserId: number;\n\t\t\tprofile: Partial<Omit<WorkProfileRecord, \"userId\">>;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): WorkProfileRecord {\n\t\t\tconst current = readProfile(input.userId);\n\t\t\tconst base = current ?? defaultProfile(input.userId);\n\t\t\tconst next: WorkProfileRecord = { ...base, ...input.profile, userId: input.userId };\n\n\t\t\tconst changes = diffFields(\n\t\t\t\tbase as unknown as Record<string, unknown>,\n\t\t\t\tnext as unknown as Record<string, unknown>,\n\t\t\t\tAUDITED_PROFILE_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tif (current && Object.keys(changes).length === 0) {\n\t\t\t\treturn current;\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupsertProfile.run(\n\t\t\t\t\tnext.userId,\n\t\t\t\t\tnext.percent,\n\t\t\t\t\tnext.weeklyHours,\n\t\t\t\t\tnext.workdays,\n\t\t\t\t\tnext.startDate,\n\t\t\t\t\tnext.endDate,\n\t\t\t\t\tnext.overtimeCarryover,\n\t\t\t\t\tnext.vorholzeitPerYear,\n\t\t\t\t\tnext.vacationCarryover,\n\t\t\t\t\tnext.vacationPerYear,\n\t\t\t\t\tnext.overtimeModel,\n\t\t\t\t\tnext.holidayFlags,\n\t\t\t\t\tnext.pausePaidMinutes,\n\t\t\t\t\tnext.showWorkedTime ? 1 : 0,\n\t\t\t\t);\n\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"user.profile\",\n\t\t\t\t\tentity: \"work_profile\",\n\t\t\t\t\tentityId: input.userId,\n\t\t\t\t\tdetail: { created: current === null, changes },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = readProfile(input.userId);\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`work profile of user ${input.userId} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn saved;\n\t\t},\n\n\t\trecordLogin(userId: number, now?: number): void {\n\t\t\tupdateLastLogin.run(now ?? Math.floor(Date.now() / 1000), userId);\n\t\t},\n\t};\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["_a", "_b"]
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/services/aggregation.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Aggregation service: keeps the day, month and year aggregates in sync with the punches.\n *\n * The daily figures come from the calculation service (a pure function); this service only supplies the\n * data (punches, employment profile, break rules, holidays, absences) and stores the result. Months and\n * years are always derived from the day rows, so a single day can be recalculated without leaving the\n * reports inconsistent.\n *\n * Days after \"today\" are not aggregated by default: future weekdays would otherwise add target time and\n * produce a phantom negative balance. Callers can pass `upToDate` (tests, imports) or `includeFuture`.\n */\n\nimport type { Db } from \"../db/database\";\nimport type { AbsencesRepository, AbsenceWithType } from \"../db/repositories/absences\";\nimport type { EntriesRepository } from \"../db/repositories/entries\";\nimport type { HolidaysRepository } from \"../db/repositories/holidays\";\nimport type { RulesRepository } from \"../db/repositories/rules\";\nimport type { SettingsRepository } from \"../db/repositories/settings\";\nimport type { UsersRepository, WorkProfileRecord } from \"../db/repositories/users\";\nimport type { WorkProfile } from \"../domain/target\";\nimport { ValidationError } from \"../errors\";\nimport { absenceDaysInRange, calculateDay, overtimeAfterPeriod } from \"../domain/calculation\";\nimport type { PauseMode } from \"../domain/breaks\";\nimport { toPunchEntries } from \"../db/repositories/entries\";\nimport { dateRange, daysInMonth, localDate as resolveLocalDate, utcToWallTime } from \"../util/time\";\n\n/** Daily aggregate as stored in `day_aggregates`. */\nexport interface DayAggregateRecord {\n\t/** Owner of the day */\n\tuserId: number;\n\t/** Local date, `YYYY-MM-DD` */\n\tlocalDate: string;\n\t/** Net working time in minutes */\n\tworkedMin: number;\n\t/** Deducted breaks in minutes */\n\tbreakMin: number;\n\t/** Part of the deducted break that is paid (in minutes) */\n\tpaidBreakMin: number;\n\t/** Target time in minutes */\n\ttargetMin: number;\n\t/** `workedMin - targetMin` */\n\tbalanceMin: number;\n\t/** Code of the absence covering the day, `null` when there is none */\n\tabsenceCode: string | null;\n\t/** True when the day is a public holiday */\n\tisHoliday: boolean;\n\t/** Instant of the first punch of the day */\n\tfirstInUtc: number | null;\n\t/** Instant of the last completed clock-out */\n\tlastOutUtc: number | null;\n\t/** True when the last punch has no counterpart yet */\n\thasOpenEntry: boolean;\n\t/** Instant of the last recalculation */\n\tupdatedAt: number;\n}\n\n/** Monthly aggregate as stored in `month_aggregates`. */\nexport interface MonthAggregateRecord {\n\t/** Owner of the month */\n\tuserId: number;\n\t/** Four digit year */\n\tyear: number;\n\t/** Month, 1 to 12 */\n\tmonth: number;\n\t/** Net working time in minutes */\n\tworkedMin: number;\n\t/** Target time in minutes */\n\ttargetMin: number;\n\t/** `workedMin - targetMin` */\n\tbalanceMin: number;\n\t/** Overtime carried at the end of the month (per model) */\n\tovertimeMin: number;\n\t/** Vacation days taken in the month */\n\tvacationUsed: number;\n\t/** Instant of the last recalculation */\n\tupdatedAt: number;\n}\n\n/** Yearly aggregate as stored in `year_aggregates`. */\nexport interface YearAggregateRecord {\n\t/** Owner of the year */\n\tuserId: number;\n\t/** Four digit year */\n\tyear: number;\n\t/** Net working time in minutes */\n\tworkedMin: number;\n\t/** Target time in minutes */\n\ttargetMin: number;\n\t/** Overtime carried at the end of the year */\n\tovertimeMin: number;\n\t/** Overtime taken over from the previous year */\n\tovertimeStart: number;\n\t/** Vacation entitlement including the carryover */\n\tvacationDays: number;\n\t/** Vacation days taken */\n\tvacationUsed: number;\n\t/** Remaining vacation days */\n\tvacationLeft: number;\n\t/** Vacation days approved but not yet taken */\n\tvacationPlannedDays: number;\n\t/** True when the year is closed */\n\tsettled: boolean;\n\t/** Instant of the last recalculation */\n\tupdatedAt: number;\n}\n\n/** Result of a range recalculation. */\nexport interface RangeResult {\n\t/** Number of recalculated days */\n\tdays: number;\n\t/** Number of days with an open punch */\n\topenDays: number;\n\t/** Net working time of the range in minutes */\n\tworkedMin: number;\n\t/** Pause of the range in minutes */\n\tbreakMin: number;\n\t/** Part of the pause that is paid (in minutes) */\n\tpaidBreakMin: number;\n\t/** Target time of the range in minutes */\n\ttargetMin: number;\n\t/** Balance of the range in minutes */\n\tbalanceMin: number;\n}\n\n/** Options shared by the recalculation methods. */\nexport interface RecalculateOptions {\n\t/** Local date up to which days are aggregated (default: today in the user's time zone) */\n\tupToDate?: string;\n\t/** True to aggregate days after `upToDate` as well */\n\tincludeFuture?: boolean;\n\t/** True to treat the year as complete for the `yearly` overtime model */\n\tsettled?: boolean;\n\t/** Instant of the recalculation, defaults to now */\n\tnow?: number;\n}\n\n/** Data sources of the aggregation service. */\nexport interface AggregationDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** User storage */\n\tusers: UsersRepository;\n\t/** Punch storage */\n\tentries: EntriesRepository;\n\t/** Absence storage */\n\tabsences: AbsencesRepository;\n\t/** Holiday storage */\n\tholidays: HolidaysRepository;\n\t/** Break and surcharge rules */\n\trules: RulesRepository;\n\t/** Instance settings */\n\tsettings: SettingsRepository;\n}\n\n/** Aggregation operations. */\nexport interface AggregationService {\n\t/** Recalculates one day and stores the aggregate */\n\trecalculateDay(userId: number, localDate: string, options?: RecalculateOptions): DayAggregateRecord;\n\t/** Recalculates every day of a range */\n\trecalculateRange(userId: number, from: string, to: string, options?: RecalculateOptions): RangeResult;\n\t/** Recalculates a month from its days and stores the monthly aggregate */\n\trecalculateMonth(userId: number, year: number, month: number, options?: RecalculateOptions): MonthAggregateRecord;\n\t/** Recalculates a year from its months and stores the yearly aggregate */\n\trecalculateYear(userId: number, year: number, options?: RecalculateOptions): YearAggregateRecord;\n\t/** Stored day aggregate */\n\tday(userId: number, localDate: string): DayAggregateRecord | null;\n\t/** Stored day aggregates of a range */\n\tdays(userId: number, from: string, to: string): DayAggregateRecord[];\n\t/** Stored monthly aggregate */\n\tmonth(userId: number, year: number, month: number): MonthAggregateRecord | null;\n\t/** Stored yearly aggregate */\n\tyear(userId: number, year: number): YearAggregateRecord | null;\n\t/** Recomputes the local date/time cache of all punches of a user (after a time zone change) */\n\trecalculateLocalCaches(userId: number): number;\n}\n\ninterface DayRow {\n\tuser_id: number;\n\tlocal_date: string;\n\tworked_min: number;\n\tbreak_min: number;\n\tpaid_break_min: number;\n\ttarget_min: number;\n\tbalance_min: number;\n\tabsence_code: string | null;\n\tis_holiday: number;\n\tfirst_in_utc: number | null;\n\tlast_out_utc: number | null;\n\thas_open_entry: number;\n\tupdated_at: number;\n}\n\ninterface MonthRow {\n\tuser_id: number;\n\tyear: number;\n\tmonth: number;\n\tworked_min: number;\n\ttarget_min: number;\n\tbalance_min: number;\n\tovertime_min: number;\n\tvacation_used: number;\n\tupdated_at: number;\n}\n\ninterface YearRow {\n\tuser_id: number;\n\tyear: number;\n\tworked_min: number;\n\ttarget_min: number;\n\tovertime_min: number;\n\tovertime_start: number;\n\tvacation_days: number;\n\tvacation_used: number;\n\tvacation_left: number;\n\tvacation_planned_days: number;\n\tsettled: number;\n\tupdated_at: number;\n}\n\nconst DAY_COLUMNS = `user_id, local_date, worked_min, break_min, paid_break_min, target_min, balance_min, absence_code,\n\\tis_holiday, first_in_utc, last_out_utc, has_open_entry, updated_at`;\n\nconst MONTH_COLUMNS = `user_id, year, month, worked_min, target_min, balance_min, overtime_min, vacation_used,\n\\tupdated_at`;\n\nconst YEAR_COLUMNS = `user_id, year, worked_min, target_min, overtime_min, overtime_start, vacation_days,\n\\tvacation_used, vacation_left, vacation_planned_days, settled, updated_at`;\n\n/**\n * Employment defaults for users without stored profile: no target time and no carryovers.\n *\n * @param userId - owner of the profile\n * @returns profile with zero values\n */\nfunction defaultProfile(userId: number): WorkProfileRecord {\n\treturn {\n\t\tuserId,\n\t\tpercent: 0,\n\t\tweeklyHours: 0,\n\t\tworkdays: \"0;1;1;1;1;1;0\",\n\t\tstartDate: null,\n\t\tendDate: null,\n\t\tovertimeCarryover: 0,\n\t\tvorholzeitPerYear: 0,\n\t\tvacationCarryover: 0,\n\t\tvacationPerYear: 0,\n\t\tovertimeModel: \"monthly\",\n\t\tholidayFlags: null,\n\t\tpausePaidMinutes: 0,\n\t};\n}\n\n/**\n * Maps a database row to a day aggregate record.\n *\n * @param row - raw database row\n * @returns day aggregate\n */\nexport function mapDayRow(row: DayRow): DayAggregateRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tlocalDate: row.local_date,\n\t\tworkedMin: row.worked_min,\n\t\tbreakMin: row.break_min,\n\t\tpaidBreakMin: row.paid_break_min,\n\t\ttargetMin: row.target_min,\n\t\tbalanceMin: row.balance_min,\n\t\tabsenceCode: row.absence_code,\n\t\tisHoliday: row.is_holiday !== 0,\n\t\tfirstInUtc: row.first_in_utc,\n\t\tlastOutUtc: row.last_out_utc,\n\t\thasOpenEntry: row.has_open_entry !== 0,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\n/**\n * Maps a database row to a monthly aggregate record.\n *\n * @param row - raw database row\n * @returns monthly aggregate\n */\nexport function mapMonthRow(row: MonthRow): MonthAggregateRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tyear: row.year,\n\t\tmonth: row.month,\n\t\tworkedMin: row.worked_min,\n\t\ttargetMin: row.target_min,\n\t\tbalanceMin: row.balance_min,\n\t\tovertimeMin: row.overtime_min,\n\t\tvacationUsed: row.vacation_used,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\n/**\n * Maps a database row to a yearly aggregate record.\n *\n * @param row - raw database row\n * @returns yearly aggregate\n */\nexport function mapYearRow(row: YearRow): YearAggregateRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tyear: row.year,\n\t\tworkedMin: row.worked_min,\n\t\ttargetMin: row.target_min,\n\t\tovertimeMin: row.overtime_min,\n\t\tovertimeStart: row.overtime_start,\n\t\tvacationDays: row.vacation_days,\n\t\tvacationUsed: row.vacation_used,\n\t\tvacationLeft: row.vacation_left,\n\t\tvacationPlannedDays: row.vacation_planned_days,\n\t\tsettled: row.settled !== 0,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\n/**\n * Creates the aggregation service.\n *\n * @param deps - data sources\n * @returns service instance\n */\nexport function createAggregationService(deps: AggregationDeps): AggregationService {\n\tconst { db, users, entries, absences, holidays, rules, settings } = deps;\n\n\tconst selectDay = db.prepare(`SELECT ${DAY_COLUMNS} FROM day_aggregates WHERE user_id = ? AND local_date = ?`);\n\tconst selectDays = db.prepare(\n\t\t`SELECT ${DAY_COLUMNS} FROM day_aggregates\n\t\t WHERE user_id = ? AND local_date >= ? AND local_date <= ?\n\t\t ORDER BY local_date`,\n\t);\n\tconst upsertDay = db.prepare(\n\t\t`INSERT INTO day_aggregates (${DAY_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id, local_date) DO UPDATE SET\n\t\t worked_min = excluded.worked_min,\n\t\t break_min = excluded.break_min,\n\t\t paid_break_min = excluded.paid_break_min,\n\t\t target_min = excluded.target_min,\n\t\t balance_min = excluded.balance_min,\n\t\t absence_code = excluded.absence_code,\n\t\t is_holiday = excluded.is_holiday,\n\t\t first_in_utc = excluded.first_in_utc,\n\t\t last_out_utc = excluded.last_out_utc,\n\t\t has_open_entry = excluded.has_open_entry,\n\t\t updated_at = excluded.updated_at`,\n\t);\n\tconst selectMonth = db.prepare(\n\t\t`SELECT ${MONTH_COLUMNS} FROM month_aggregates WHERE user_id = ? AND year = ? AND month = ?`,\n\t);\n\tconst upsertMonth = db.prepare(\n\t\t`INSERT INTO month_aggregates (${MONTH_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id, year, month) DO UPDATE SET\n\t\t worked_min = excluded.worked_min,\n\t\t target_min = excluded.target_min,\n\t\t balance_min = excluded.balance_min,\n\t\t overtime_min = excluded.overtime_min,\n\t\t vacation_used = excluded.vacation_used,\n\t\t updated_at = excluded.updated_at`,\n\t);\n\tconst selectYear = db.prepare(`SELECT ${YEAR_COLUMNS} FROM year_aggregates WHERE user_id = ? AND year = ?`);\n\tconst upsertYear = db.prepare(\n\t\t`INSERT INTO year_aggregates (${YEAR_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id, year) DO UPDATE SET\n\t\t worked_min = excluded.worked_min,\n\t\t target_min = excluded.target_min,\n\t\t overtime_min = excluded.overtime_min,\n\t\t overtime_start = excluded.overtime_start,\n\t\t vacation_days = excluded.vacation_days,\n\t\t vacation_used = excluded.vacation_used,\n\t\t vacation_left = excluded.vacation_left,\n\t\t vacation_planned_days = excluded.vacation_planned_days,\n\t\t settled = excluded.settled,\n\t\t updated_at = excluded.updated_at`,\n\t);\n\tconst selectPayouts = db.prepare(\n\t\t\"SELECT COALESCE(SUM(minutes), 0) AS minutes FROM payouts WHERE user_id = ? AND year = ?\",\n\t);\n\tconst selectEntryCache = db.prepare(\"SELECT id, ts_utc AS tsUtc FROM time_entries WHERE user_id = ?\");\n\tconst updateEntryCache = db.prepare(\"UPDATE time_entries SET ts_local = ?, local_date = ? WHERE id = ?\");\n\n\tconst timeZoneOf = (userId: number): string => users.findById(userId)?.timezone ?? \"UTC\";\n\tconst holidayRegion = (): string => settings.get(\"holiday_country\") ?? \"DE\";\n\n\t/**\n\t * How the pause of a day is determined.\n\t *\n\t * A stored value that is not one of the three modes falls back to the default, so a hand edited setting can\n\t * never spoil a calculation.\n\t *\n\t * @returns the configured pause mode\n\t */\n\tconst pauseMode = (): PauseMode => {\n\t\tconst stored = settings.get(\"pause_mode\");\n\t\treturn stored === \"punched\" || stored === \"staffel\" ? stored : \"auto\";\n\t};\n\n\t/**\n\t * Reads the stored profile or the defaults (no target time, no carryovers).\n\t *\n\t * @param userId - owner of the profile\n\t * @returns employment parameters\n\t */\n\tconst profileOf = (userId: number): WorkProfileRecord => users.getWorkProfile(userId) ?? defaultProfile(userId);\n\n\t/**\n\t * Picks the absence of a day: the one crediting the largest part of the day wins, ties are broken by id.\n\t *\n\t * @param userId - owner of the absence\n\t * @param date - local date\n\t * @returns the covering absence or `null`\n\t */\n\tconst absenceFor = (userId: number, date: string): AbsenceWithType | null => {\n\t\tconst covering = absences.withTypesInRange(userId, date, date);\n\t\tlet best: AbsenceWithType | null = null;\n\t\tfor (const absence of covering) {\n\t\t\tconst share = (Math.max(0, Math.min(100, absence.factor)) / 100) * absence.dayPortion;\n\t\t\tconst bestShare = best ? (Math.max(0, Math.min(100, best.factor)) / 100) * best.dayPortion : -1;\n\t\t\tif (share > bestShare || (share === bestShare && best !== null && absence.id < best.id)) {\n\t\t\t\tbest = absence;\n\t\t\t}\n\t\t}\n\t\treturn best;\n\t};\n\n\t/**\n\t * Sum of the payouts of a year.\n\t *\n\t * @param userId - owner of the payouts\n\t * @param year - four digit year\n\t * @returns paid out minutes\n\t */\n\tconst payoutsOf = (userId: number, year: number): number =>\n\t\t(selectPayouts.get(userId, year) as { minutes: number }).minutes;\n\n\t/**\n\t * Counts the months of a year that lie inside the employment period.\n\t *\n\t * @param profile - employment parameters\n\t * @param year - four digit year\n\t * @param upToMonth - last month to count (1 to 12)\n\t * @param timeZone - time zone used to resolve the employment dates\n\t * @returns number of active months\n\t */\n\tconst activeMonthsInYear = (profile: WorkProfile, year: number, upToMonth: number, timeZone: string): number => {\n\t\tconst startDate = profile.startDate == null ? null : resolveLocalDate(profile.startDate, timeZone);\n\t\tconst endDate = profile.endDate == null ? null : resolveLocalDate(profile.endDate, timeZone);\n\t\tconst limit = Math.max(0, Math.min(12, upToMonth));\n\t\tlet months = 0;\n\n\t\tfor (let month = 1; month <= limit; month++) {\n\t\t\tconst key = `${year}-${String(month).padStart(2, \"0\")}`;\n\t\t\tconst first = `${key}-01`;\n\t\t\tconst last = `${key}-${String(daysInMonth(year, month)).padStart(2, \"0\")}`;\n\t\t\tif ((startDate === null || last >= startDate) && (endDate === null || first <= endDate)) {\n\t\t\t\tmonths++;\n\t\t\t}\n\t\t}\n\t\treturn months;\n\t};\n\n\t/**\n\t * Overtime taken over at the beginning of a year: the previous year's result or the profile carryover.\n\t *\n\t * @param userId - owner\n\t * @param year - four digit year\n\t * @param carryover - carryover of the profile (used for the first year)\n\t * @returns overtime in minutes\n\t */\n\tconst overtimeStartFor = (userId: number, year: number, carryover: number): number => {\n\t\tconst previous = selectYear.get(userId, year - 1) as YearRow | undefined;\n\t\treturn previous ? previous.overtime_min : carryover;\n\t};\n\n\t/**\n\t * Overtime carried after a month, following the model of the profile.\n\t *\n\t * @param args - month, balances, profile and flags\n\t * @param args.userId - owner\n\t * @param args.year - four digit year\n\t * @param args.month - month, 1 to 12\n\t * @param args.monthBalance - freshly calculated balance of that month (`null` to read it from the database)\n\t * @param args.profile - employment parameters\n\t * @param args.timeZone - time zone of the employee\n\t * @param args.completeYear - true when the calendar year is finished\n\t * @returns carried overtime in minutes\n\t */\n\tconst overtimeAfterMonth = (args: {\n\t\tuserId: number;\n\t\tyear: number;\n\t\tmonth: number;\n\t\tmonthBalance: number | null;\n\t\tprofile: WorkProfileRecord;\n\t\ttimeZone: string;\n\t\tcompleteYear: boolean;\n\t}): number => {\n\t\tconst balances: number[] = [];\n\t\tfor (let index = 1; index <= args.month; index++) {\n\t\t\tif (index === args.month && args.monthBalance !== null) {\n\t\t\t\tbalances.push(args.monthBalance);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst row = selectMonth.get(args.userId, args.year, index) as MonthRow | undefined;\n\t\t\tbalances.push(row?.balance_min ?? 0);\n\t\t}\n\n\t\treturn overtimeAfterPeriod({\n\t\t\tmodel: args.profile.overtimeModel,\n\t\t\tmonthBalances: balances,\n\t\t\tovertimeStartMinutes: overtimeStartFor(args.userId, args.year, args.profile.overtimeCarryover),\n\t\t\tvorholzeitPerYearMinutes: args.profile.vorholzeitPerYear,\n\t\t\tactiveMonths: activeMonthsInYear(args.profile, args.year, args.month, args.timeZone),\n\t\t\tpayoutMinutes: payoutsOf(args.userId, args.year),\n\t\t\tcompleteYear: args.completeYear,\n\t\t}).overtimeMinutes;\n\t};\n\n\t/**\n\t * Vacation days of one kind inside a month or year.\n\t *\n\t * @param args - user, period and status\n\t * @param args.userId - owner\n\t * @param args.from - first local date\n\t * @param args.to - last local date\n\t * @param args.status - `taken` for used days, `planned` for approved days\n\t * @param args.profile - employment parameters (working days)\n\t * @param args.timeZone - time zone of the employee\n\t * @returns vacation days, rounded to two decimals\n\t */\n\tconst vacationDaysInRange = (args: {\n\t\tuserId: number;\n\t\tfrom: string;\n\t\tto: string;\n\t\tstatus: \"taken\" | \"planned\";\n\t\tprofile: WorkProfileRecord;\n\t\ttimeZone: string;\n\t}): number => {\n\t\tconst year = Number(args.to.slice(0, 4));\n\t\tconst holidayDates = holidays.dateSet(year, holidayRegion());\n\t\tconst overlapping = absences.withTypesInRange(args.userId, args.from, args.to);\n\n\t\tlet days = 0;\n\t\tfor (const absence of overlapping) {\n\t\t\tif (!absence.reduceVacation || absence.status !== args.status) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst from = absence.dateFrom < args.from ? args.from : absence.dateFrom;\n\t\t\tconst to = absence.dateTo > args.to ? args.to : absence.dateTo;\n\t\t\tif (from > to) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tdays += absenceDaysInRange({\n\t\t\t\tfrom,\n\t\t\t\tto,\n\t\t\t\tworkdays: args.profile.workdays,\n\t\t\t\ttimeZone: args.timeZone,\n\t\t\t\tholidays: holidayDates,\n\t\t\t\tdayPortion: absence.dayPortion,\n\t\t\t});\n\t\t}\n\t\treturn Math.round(days * 100) / 100;\n\t};\n\n\t/**\n\t * True when a calendar year is finished (needed by the `yearly` overtime model).\n\t *\n\t * @param year - four digit year\n\t * @param todayDate - local date of the user\n\t * @param settled - explicit flag of the caller\n\t * @returns true when the year is closed\n\t */\n\tconst isCompleteYear = (year: number, todayDate: string, settled?: boolean): boolean =>\n\t\tsettled === true || Number(todayDate.slice(0, 4)) > year;\n\n\t/**\n\t * Calculates one day from the stored data (profile, rules, holidays, absences, punches).\n\t *\n\t * @param userId - owner\n\t * @param localDate - local date to calculate\n\t * @param now - instant of the recalculation\n\t * @returns the calculated day\n\t */\n\tconst calculateDayFor = (userId: number, localDate: string, now: number): DayAggregateRecord => {\n\t\tconst timeZone = timeZoneOf(userId);\n\t\tconst absence = absenceFor(userId, localDate);\n\t\tconst isHoliday = holidays.isHoliday(localDate, holidayRegion());\n\t\tconst profile = profileOf(userId);\n\t\tconst day = calculateDay({\n\t\t\tprofile,\n\t\t\tentries: toPunchEntries(entries.listByDate(userId, localDate)),\n\t\t\tpauseRules: rules.pauseRules(userId),\n\t\t\tpauseMode: pauseMode(),\n\t\t\tpausePaidMinutes: profile.pausePaidMinutes,\n\t\t\ttimeZone,\n\t\t\tlocalDate,\n\t\t\tisHoliday,\n\t\t\tabsence: absence ? { factor: absence.factor, dayPortion: absence.dayPortion } : null,\n\t\t});\n\n\t\treturn {\n\t\t\tuserId,\n\t\t\tlocalDate,\n\t\t\tworkedMin: day.workedMinutes,\n\t\t\tbreakMin: day.breakMinutes,\n\t\t\tpaidBreakMin: day.paidPauseMinutes,\n\t\t\ttargetMin: day.targetMinutes,\n\t\t\tbalanceMin: day.balanceMinutes,\n\t\t\tabsenceCode: absence?.typeCode ?? null,\n\t\t\tisHoliday,\n\t\t\tfirstInUtc: day.firstInUtc,\n\t\t\tlastOutUtc: day.lastOutUtc,\n\t\t\thasOpenEntry: day.hasOpenEntry,\n\t\t\tupdatedAt: now,\n\t\t};\n\t};\n\n\treturn {\n\t\trecalculateDay(userId: number, localDate: string, options?: RecalculateOptions): DayAggregateRecord {\n\t\t\tconst now = options?.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst record = calculateDayFor(userId, localDate, now);\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupsertDay.run(\n\t\t\t\t\trecord.userId,\n\t\t\t\t\trecord.localDate,\n\t\t\t\t\trecord.workedMin,\n\t\t\t\t\trecord.breakMin,\n\t\t\t\t\trecord.paidBreakMin,\n\t\t\t\t\trecord.targetMin,\n\t\t\t\t\trecord.balanceMin,\n\t\t\t\t\trecord.absenceCode,\n\t\t\t\t\trecord.isHoliday ? 1 : 0,\n\t\t\t\t\trecord.firstInUtc,\n\t\t\t\t\trecord.lastOutUtc,\n\t\t\t\t\trecord.hasOpenEntry ? 1 : 0,\n\t\t\t\t\trecord.updatedAt,\n\t\t\t\t);\n\t\t\t});\n\t\t\trun();\n\t\t\treturn record;\n\t\t},\n\n\t\trecalculateRange(userId: number, from: string, to: string, options?: RecalculateOptions): RangeResult {\n\t\t\tconst totals: RangeResult = {\n\t\t\t\tdays: 0,\n\t\t\t\topenDays: 0,\n\t\t\t\tworkedMin: 0,\n\t\t\t\tbreakMin: 0,\n\t\t\t\tpaidBreakMin: 0,\n\t\t\t\ttargetMin: 0,\n\t\t\t\tbalanceMin: 0,\n\t\t\t};\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const date of dateRange(from, to)) {\n\t\t\t\t\tconst record = this.recalculateDay(userId, date, options);\n\t\t\t\t\ttotals.days++;\n\t\t\t\t\ttotals.workedMin += record.workedMin;\n\t\t\t\t\ttotals.breakMin += record.breakMin;\n\t\t\t\t\ttotals.paidBreakMin += record.paidBreakMin;\n\t\t\t\t\ttotals.targetMin += record.targetMin;\n\t\t\t\t\ttotals.balanceMin += record.balanceMin;\n\t\t\t\t\ttotals.openDays += record.hasOpenEntry ? 1 : 0;\n\t\t\t\t}\n\t\t\t});\n\t\t\trun();\n\t\t\treturn totals;\n\t\t},\n\n\t\trecalculateMonth(\n\t\t\tuserId: number,\n\t\t\tyear: number,\n\t\t\tmonth: number,\n\t\t\toptions?: RecalculateOptions,\n\t\t): MonthAggregateRecord {\n\t\t\tif (!Number.isInteger(month) || month < 1 || month > 12) {\n\t\t\t\tthrow new ValidationError(`month must be between 1 and 12 (got ${month})`);\n\t\t\t}\n\n\t\t\tconst now = options?.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst timeZone = timeZoneOf(userId);\n\t\t\tconst profile = profileOf(userId);\n\t\t\tconst upToDate = options?.upToDate ?? resolveLocalDate(now, timeZone);\n\t\t\tconst key = `${year}-${String(month).padStart(2, \"0\")}`;\n\t\t\tconst firstDay = `${key}-01`;\n\t\t\tconst lastDay = `${key}-${String(daysInMonth(year, month)).padStart(2, \"0\")}`;\n\t\t\t// days after `upToDate` are skipped unless the caller asks for them\n\t\t\tconst endDate = options?.includeFuture === true || lastDay <= upToDate ? lastDay : upToDate;\n\n\t\t\tlet workedMin = 0;\n\t\t\tlet targetMin = 0;\n\t\t\tlet balanceMin = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const date of dateRange(firstDay, endDate)) {\n\t\t\t\t\tconst record = this.recalculateDay(userId, date, options);\n\t\t\t\t\tworkedMin += record.workedMin;\n\t\t\t\t\ttargetMin += record.targetMin;\n\t\t\t\t\tbalanceMin += record.balanceMin;\n\t\t\t\t}\n\n\t\t\t\tconst vacationUsed = vacationDaysInRange({\n\t\t\t\t\tuserId,\n\t\t\t\t\tfrom: firstDay,\n\t\t\t\t\tto: lastDay,\n\t\t\t\t\tstatus: \"taken\",\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t});\n\t\t\t\tconst overtimeMin = overtimeAfterMonth({\n\t\t\t\t\tuserId,\n\t\t\t\t\tyear,\n\t\t\t\t\tmonth,\n\t\t\t\t\tmonthBalance: balanceMin,\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t\tcompleteYear: isCompleteYear(year, upToDate, options?.settled),\n\t\t\t\t});\n\n\t\t\t\tupsertMonth.run(userId, year, month, workedMin, targetMin, balanceMin, overtimeMin, vacationUsed, now);\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = selectMonth.get(userId, year, month) as MonthRow | undefined;\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`month aggregate ${key} disappeared right after the write`);\n\t\t\t}\n\t\t\treturn mapMonthRow(saved);\n\t\t},\n\n\t\trecalculateYear(userId: number, year: number, options?: RecalculateOptions): YearAggregateRecord {\n\t\t\tconst now = options?.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst timeZone = timeZoneOf(userId);\n\t\t\tconst profile = profileOf(userId);\n\t\t\tconst upToDate = options?.upToDate ?? resolveLocalDate(now, timeZone);\n\t\t\tconst settled = isCompleteYear(year, upToDate, options?.settled);\n\t\t\tconst yearStart = `${year}-01-01`;\n\t\t\tconst yearEnd = `${year}-12-31`;\n\n\t\t\tlet workedMin = 0;\n\t\t\tlet targetMin = 0;\n\t\t\tlet overtimeMin = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (let month = 1; month <= 12; month++) {\n\t\t\t\t\tconst row = this.recalculateMonth(userId, year, month, options);\n\t\t\t\t\tworkedMin += row.workedMin;\n\t\t\t\t\ttargetMin += row.targetMin;\n\t\t\t\t}\n\t\t\t\t// the carried overtime after December is the result of the year\n\t\t\t\tovertimeMin = overtimeAfterMonth({\n\t\t\t\t\tuserId,\n\t\t\t\t\tyear,\n\t\t\t\t\tmonth: 12,\n\t\t\t\t\tmonthBalance: null,\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t\tcompleteYear: settled,\n\t\t\t\t});\n\n\t\t\t\tconst vacationDays = profile.vacationCarryover + profile.vacationPerYear;\n\t\t\t\tconst vacationUsed = vacationDaysInRange({\n\t\t\t\t\tuserId,\n\t\t\t\t\tfrom: yearStart,\n\t\t\t\t\tto: yearEnd,\n\t\t\t\t\tstatus: \"taken\",\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t});\n\t\t\t\tconst vacationPlanned = vacationDaysInRange({\n\t\t\t\t\tuserId,\n\t\t\t\t\tfrom: yearStart,\n\t\t\t\t\tto: yearEnd,\n\t\t\t\t\tstatus: \"planned\",\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t});\n\n\t\t\t\tupsertYear.run(\n\t\t\t\t\tuserId,\n\t\t\t\t\tyear,\n\t\t\t\t\tworkedMin,\n\t\t\t\t\ttargetMin,\n\t\t\t\t\tovertimeMin,\n\t\t\t\t\tovertimeStartFor(userId, year, profile.overtimeCarryover),\n\t\t\t\t\tMath.round(vacationDays * 100) / 100,\n\t\t\t\t\tvacationUsed,\n\t\t\t\t\tMath.round((vacationDays - vacationUsed) * 100) / 100,\n\t\t\t\t\tvacationPlanned,\n\t\t\t\t\tsettled ? 1 : 0,\n\t\t\t\t\tnow,\n\t\t\t\t);\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = selectYear.get(userId, year) as YearRow | undefined;\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`year aggregate ${year} disappeared right after the write`);\n\t\t\t}\n\t\t\treturn mapYearRow(saved);\n\t\t},\n\n\t\tday(userId: number, localDate: string): DayAggregateRecord | null {\n\t\t\tconst row = selectDay.get(userId, localDate) as DayRow | undefined;\n\t\t\treturn row ? mapDayRow(row) : null;\n\t\t},\n\n\t\tdays(userId: number, from: string, to: string): DayAggregateRecord[] {\n\t\t\treturn (selectDays.all(userId, from, to) as DayRow[]).map(mapDayRow);\n\t\t},\n\n\t\tmonth(userId: number, year: number, month: number): MonthAggregateRecord | null {\n\t\t\tconst row = selectMonth.get(userId, year, month) as MonthRow | undefined;\n\t\t\treturn row ? mapMonthRow(row) : null;\n\t\t},\n\n\t\tyear(userId: number, year: number): YearAggregateRecord | null {\n\t\t\tconst row = selectYear.get(userId, year) as YearRow | undefined;\n\t\t\treturn row ? mapYearRow(row) : null;\n\t\t},\n\n\t\trecalculateLocalCaches(userId: number): number {\n\t\t\tconst timeZone = timeZoneOf(userId);\n\t\t\tconst rows = selectEntryCache.all(userId) as { id: number; tsUtc: number }[];\n\t\t\tlet changed = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const row of rows) {\n\t\t\t\t\tconst tsLocal = utcToWallTime(row.tsUtc, timeZone);\n\t\t\t\t\tconst date = resolveLocalDate(row.tsUtc, timeZone);\n\t\t\t\t\tif (cacheMatches(db, row.id, tsLocal, date)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tupdateEntryCache.run(tsLocal, date, row.id);\n\t\t\t\t\tchanged++;\n\t\t\t\t}\n\t\t\t});\n\t\t\trun();\n\t\t\treturn changed;\n\t\t},\n\t};\n}\n\n/**\n * Checks whether the local cache fields of a punch already match.\n *\n * @param db - open database handle\n * @param entryId - punch id\n * @param tsLocal - expected wall clock time\n * @param localDate - expected local date\n * @returns true when both values are already stored\n */\nfunction cacheMatches(db: Db, entryId: number, tsLocal: number, localDate: string): boolean {\n\tconst row = db\n\t\t.prepare(\"SELECT ts_local AS tsLocal, local_date AS localDate FROM time_entries WHERE id = ?\")\n\t\t.get(entryId) as { tsLocal: number; localDate: string } | undefined;\n\treturn row !== undefined && row.tsLocal === tsLocal && row.localDate === localDate;\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoBA,oBAAgC;AAChC,yBAAsE;AAEtE,qBAA+B;AAC/B,kBAAqF;AAmMrF,MAAM,cAAc;AAAA;AAGpB,MAAM,gBAAgB;AAAA;AAGtB,MAAM,eAAe;AAAA;AASrB,SAAS,eAAe,QAAmC;AAC1D,SAAO;AAAA,IACN;AAAA,IACA,SAAS;AAAA,IACT,aAAa;AAAA,IACb,UAAU;AAAA,IACV,WAAW;AAAA,IACX,SAAS;AAAA,IACT,mBAAmB;AAAA,IACnB,mBAAmB;AAAA,IACnB,mBAAmB;AAAA,IACnB,iBAAiB;AAAA,IACjB,eAAe;AAAA,IACf,cAAc;AAAA,IACd,kBAAkB;AAAA,
|
|
4
|
+
"sourcesContent": ["/**\n * Aggregation service: keeps the day, month and year aggregates in sync with the punches.\n *\n * The daily figures come from the calculation service (a pure function); this service only supplies the\n * data (punches, employment profile, break rules, holidays, absences) and stores the result. Months and\n * years are always derived from the day rows, so a single day can be recalculated without leaving the\n * reports inconsistent.\n *\n * Days after \"today\" are not aggregated by default: future weekdays would otherwise add target time and\n * produce a phantom negative balance. Callers can pass `upToDate` (tests, imports) or `includeFuture`.\n */\n\nimport type { Db } from \"../db/database\";\nimport type { AbsencesRepository, AbsenceWithType } from \"../db/repositories/absences\";\nimport type { EntriesRepository } from \"../db/repositories/entries\";\nimport type { HolidaysRepository } from \"../db/repositories/holidays\";\nimport type { RulesRepository } from \"../db/repositories/rules\";\nimport type { SettingsRepository } from \"../db/repositories/settings\";\nimport type { UsersRepository, WorkProfileRecord } from \"../db/repositories/users\";\nimport type { WorkProfile } from \"../domain/target\";\nimport { ValidationError } from \"../errors\";\nimport { absenceDaysInRange, calculateDay, overtimeAfterPeriod } from \"../domain/calculation\";\nimport type { PauseMode } from \"../domain/breaks\";\nimport { toPunchEntries } from \"../db/repositories/entries\";\nimport { dateRange, daysInMonth, localDate as resolveLocalDate, utcToWallTime } from \"../util/time\";\n\n/** Daily aggregate as stored in `day_aggregates`. */\nexport interface DayAggregateRecord {\n\t/** Owner of the day */\n\tuserId: number;\n\t/** Local date, `YYYY-MM-DD` */\n\tlocalDate: string;\n\t/** Net working time in minutes */\n\tworkedMin: number;\n\t/** Deducted breaks in minutes */\n\tbreakMin: number;\n\t/** Part of the deducted break that is paid (in minutes) */\n\tpaidBreakMin: number;\n\t/** Target time in minutes */\n\ttargetMin: number;\n\t/** `workedMin - targetMin` */\n\tbalanceMin: number;\n\t/** Code of the absence covering the day, `null` when there is none */\n\tabsenceCode: string | null;\n\t/** True when the day is a public holiday */\n\tisHoliday: boolean;\n\t/** Instant of the first punch of the day */\n\tfirstInUtc: number | null;\n\t/** Instant of the last completed clock-out */\n\tlastOutUtc: number | null;\n\t/** True when the last punch has no counterpart yet */\n\thasOpenEntry: boolean;\n\t/** Instant of the last recalculation */\n\tupdatedAt: number;\n}\n\n/** Monthly aggregate as stored in `month_aggregates`. */\nexport interface MonthAggregateRecord {\n\t/** Owner of the month */\n\tuserId: number;\n\t/** Four digit year */\n\tyear: number;\n\t/** Month, 1 to 12 */\n\tmonth: number;\n\t/** Net working time in minutes */\n\tworkedMin: number;\n\t/** Target time in minutes */\n\ttargetMin: number;\n\t/** `workedMin - targetMin` */\n\tbalanceMin: number;\n\t/** Overtime carried at the end of the month (per model) */\n\tovertimeMin: number;\n\t/** Vacation days taken in the month */\n\tvacationUsed: number;\n\t/** Instant of the last recalculation */\n\tupdatedAt: number;\n}\n\n/** Yearly aggregate as stored in `year_aggregates`. */\nexport interface YearAggregateRecord {\n\t/** Owner of the year */\n\tuserId: number;\n\t/** Four digit year */\n\tyear: number;\n\t/** Net working time in minutes */\n\tworkedMin: number;\n\t/** Target time in minutes */\n\ttargetMin: number;\n\t/** Overtime carried at the end of the year */\n\tovertimeMin: number;\n\t/** Overtime taken over from the previous year */\n\tovertimeStart: number;\n\t/** Vacation entitlement including the carryover */\n\tvacationDays: number;\n\t/** Vacation days taken */\n\tvacationUsed: number;\n\t/** Remaining vacation days */\n\tvacationLeft: number;\n\t/** Vacation days approved but not yet taken */\n\tvacationPlannedDays: number;\n\t/** True when the year is closed */\n\tsettled: boolean;\n\t/** Instant of the last recalculation */\n\tupdatedAt: number;\n}\n\n/** Result of a range recalculation. */\nexport interface RangeResult {\n\t/** Number of recalculated days */\n\tdays: number;\n\t/** Number of days with an open punch */\n\topenDays: number;\n\t/** Net working time of the range in minutes */\n\tworkedMin: number;\n\t/** Pause of the range in minutes */\n\tbreakMin: number;\n\t/** Part of the pause that is paid (in minutes) */\n\tpaidBreakMin: number;\n\t/** Target time of the range in minutes */\n\ttargetMin: number;\n\t/** Balance of the range in minutes */\n\tbalanceMin: number;\n}\n\n/** Options shared by the recalculation methods. */\nexport interface RecalculateOptions {\n\t/** Local date up to which days are aggregated (default: today in the user's time zone) */\n\tupToDate?: string;\n\t/** True to aggregate days after `upToDate` as well */\n\tincludeFuture?: boolean;\n\t/** True to treat the year as complete for the `yearly` overtime model */\n\tsettled?: boolean;\n\t/** Instant of the recalculation, defaults to now */\n\tnow?: number;\n}\n\n/** Data sources of the aggregation service. */\nexport interface AggregationDeps {\n\t/** Open database handle */\n\tdb: Db;\n\t/** User storage */\n\tusers: UsersRepository;\n\t/** Punch storage */\n\tentries: EntriesRepository;\n\t/** Absence storage */\n\tabsences: AbsencesRepository;\n\t/** Holiday storage */\n\tholidays: HolidaysRepository;\n\t/** Break and surcharge rules */\n\trules: RulesRepository;\n\t/** Instance settings */\n\tsettings: SettingsRepository;\n}\n\n/** Aggregation operations. */\nexport interface AggregationService {\n\t/** Recalculates one day and stores the aggregate */\n\trecalculateDay(userId: number, localDate: string, options?: RecalculateOptions): DayAggregateRecord;\n\t/** Recalculates every day of a range */\n\trecalculateRange(userId: number, from: string, to: string, options?: RecalculateOptions): RangeResult;\n\t/** Recalculates a month from its days and stores the monthly aggregate */\n\trecalculateMonth(userId: number, year: number, month: number, options?: RecalculateOptions): MonthAggregateRecord;\n\t/** Recalculates a year from its months and stores the yearly aggregate */\n\trecalculateYear(userId: number, year: number, options?: RecalculateOptions): YearAggregateRecord;\n\t/** Stored day aggregate */\n\tday(userId: number, localDate: string): DayAggregateRecord | null;\n\t/** Stored day aggregates of a range */\n\tdays(userId: number, from: string, to: string): DayAggregateRecord[];\n\t/** Stored monthly aggregate */\n\tmonth(userId: number, year: number, month: number): MonthAggregateRecord | null;\n\t/** Stored yearly aggregate */\n\tyear(userId: number, year: number): YearAggregateRecord | null;\n\t/** Recomputes the local date/time cache of all punches of a user (after a time zone change) */\n\trecalculateLocalCaches(userId: number): number;\n}\n\ninterface DayRow {\n\tuser_id: number;\n\tlocal_date: string;\n\tworked_min: number;\n\tbreak_min: number;\n\tpaid_break_min: number;\n\ttarget_min: number;\n\tbalance_min: number;\n\tabsence_code: string | null;\n\tis_holiday: number;\n\tfirst_in_utc: number | null;\n\tlast_out_utc: number | null;\n\thas_open_entry: number;\n\tupdated_at: number;\n}\n\ninterface MonthRow {\n\tuser_id: number;\n\tyear: number;\n\tmonth: number;\n\tworked_min: number;\n\ttarget_min: number;\n\tbalance_min: number;\n\tovertime_min: number;\n\tvacation_used: number;\n\tupdated_at: number;\n}\n\ninterface YearRow {\n\tuser_id: number;\n\tyear: number;\n\tworked_min: number;\n\ttarget_min: number;\n\tovertime_min: number;\n\tovertime_start: number;\n\tvacation_days: number;\n\tvacation_used: number;\n\tvacation_left: number;\n\tvacation_planned_days: number;\n\tsettled: number;\n\tupdated_at: number;\n}\n\nconst DAY_COLUMNS = `user_id, local_date, worked_min, break_min, paid_break_min, target_min, balance_min, absence_code,\n\\tis_holiday, first_in_utc, last_out_utc, has_open_entry, updated_at`;\n\nconst MONTH_COLUMNS = `user_id, year, month, worked_min, target_min, balance_min, overtime_min, vacation_used,\n\\tupdated_at`;\n\nconst YEAR_COLUMNS = `user_id, year, worked_min, target_min, overtime_min, overtime_start, vacation_days,\n\\tvacation_used, vacation_left, vacation_planned_days, settled, updated_at`;\n\n/**\n * Employment defaults for users without stored profile: no target time and no carryovers.\n *\n * @param userId - owner of the profile\n * @returns profile with zero values\n */\nfunction defaultProfile(userId: number): WorkProfileRecord {\n\treturn {\n\t\tuserId,\n\t\tpercent: 0,\n\t\tweeklyHours: 0,\n\t\tworkdays: \"0;1;1;1;1;1;0\",\n\t\tstartDate: null,\n\t\tendDate: null,\n\t\tovertimeCarryover: 0,\n\t\tvorholzeitPerYear: 0,\n\t\tvacationCarryover: 0,\n\t\tvacationPerYear: 0,\n\t\tovertimeModel: \"monthly\",\n\t\tholidayFlags: null,\n\t\tpausePaidMinutes: 0,\n\t\tshowWorkedTime: false,\n\t};\n}\n\n/**\n * Maps a database row to a day aggregate record.\n *\n * @param row - raw database row\n * @returns day aggregate\n */\nexport function mapDayRow(row: DayRow): DayAggregateRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tlocalDate: row.local_date,\n\t\tworkedMin: row.worked_min,\n\t\tbreakMin: row.break_min,\n\t\tpaidBreakMin: row.paid_break_min,\n\t\ttargetMin: row.target_min,\n\t\tbalanceMin: row.balance_min,\n\t\tabsenceCode: row.absence_code,\n\t\tisHoliday: row.is_holiday !== 0,\n\t\tfirstInUtc: row.first_in_utc,\n\t\tlastOutUtc: row.last_out_utc,\n\t\thasOpenEntry: row.has_open_entry !== 0,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\n/**\n * Maps a database row to a monthly aggregate record.\n *\n * @param row - raw database row\n * @returns monthly aggregate\n */\nexport function mapMonthRow(row: MonthRow): MonthAggregateRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tyear: row.year,\n\t\tmonth: row.month,\n\t\tworkedMin: row.worked_min,\n\t\ttargetMin: row.target_min,\n\t\tbalanceMin: row.balance_min,\n\t\tovertimeMin: row.overtime_min,\n\t\tvacationUsed: row.vacation_used,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\n/**\n * Maps a database row to a yearly aggregate record.\n *\n * @param row - raw database row\n * @returns yearly aggregate\n */\nexport function mapYearRow(row: YearRow): YearAggregateRecord {\n\treturn {\n\t\tuserId: row.user_id,\n\t\tyear: row.year,\n\t\tworkedMin: row.worked_min,\n\t\ttargetMin: row.target_min,\n\t\tovertimeMin: row.overtime_min,\n\t\tovertimeStart: row.overtime_start,\n\t\tvacationDays: row.vacation_days,\n\t\tvacationUsed: row.vacation_used,\n\t\tvacationLeft: row.vacation_left,\n\t\tvacationPlannedDays: row.vacation_planned_days,\n\t\tsettled: row.settled !== 0,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n\n/**\n * Creates the aggregation service.\n *\n * @param deps - data sources\n * @returns service instance\n */\nexport function createAggregationService(deps: AggregationDeps): AggregationService {\n\tconst { db, users, entries, absences, holidays, rules, settings } = deps;\n\n\tconst selectDay = db.prepare(`SELECT ${DAY_COLUMNS} FROM day_aggregates WHERE user_id = ? AND local_date = ?`);\n\tconst selectDays = db.prepare(\n\t\t`SELECT ${DAY_COLUMNS} FROM day_aggregates\n\t\t WHERE user_id = ? AND local_date >= ? AND local_date <= ?\n\t\t ORDER BY local_date`,\n\t);\n\tconst upsertDay = db.prepare(\n\t\t`INSERT INTO day_aggregates (${DAY_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id, local_date) DO UPDATE SET\n\t\t worked_min = excluded.worked_min,\n\t\t break_min = excluded.break_min,\n\t\t paid_break_min = excluded.paid_break_min,\n\t\t target_min = excluded.target_min,\n\t\t balance_min = excluded.balance_min,\n\t\t absence_code = excluded.absence_code,\n\t\t is_holiday = excluded.is_holiday,\n\t\t first_in_utc = excluded.first_in_utc,\n\t\t last_out_utc = excluded.last_out_utc,\n\t\t has_open_entry = excluded.has_open_entry,\n\t\t updated_at = excluded.updated_at`,\n\t);\n\tconst selectMonth = db.prepare(\n\t\t`SELECT ${MONTH_COLUMNS} FROM month_aggregates WHERE user_id = ? AND year = ? AND month = ?`,\n\t);\n\tconst upsertMonth = db.prepare(\n\t\t`INSERT INTO month_aggregates (${MONTH_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id, year, month) DO UPDATE SET\n\t\t worked_min = excluded.worked_min,\n\t\t target_min = excluded.target_min,\n\t\t balance_min = excluded.balance_min,\n\t\t overtime_min = excluded.overtime_min,\n\t\t vacation_used = excluded.vacation_used,\n\t\t updated_at = excluded.updated_at`,\n\t);\n\tconst selectYear = db.prepare(`SELECT ${YEAR_COLUMNS} FROM year_aggregates WHERE user_id = ? AND year = ?`);\n\tconst upsertYear = db.prepare(\n\t\t`INSERT INTO year_aggregates (${YEAR_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t ON CONFLICT(user_id, year) DO UPDATE SET\n\t\t worked_min = excluded.worked_min,\n\t\t target_min = excluded.target_min,\n\t\t overtime_min = excluded.overtime_min,\n\t\t overtime_start = excluded.overtime_start,\n\t\t vacation_days = excluded.vacation_days,\n\t\t vacation_used = excluded.vacation_used,\n\t\t vacation_left = excluded.vacation_left,\n\t\t vacation_planned_days = excluded.vacation_planned_days,\n\t\t settled = excluded.settled,\n\t\t updated_at = excluded.updated_at`,\n\t);\n\tconst selectPayouts = db.prepare(\n\t\t\"SELECT COALESCE(SUM(minutes), 0) AS minutes FROM payouts WHERE user_id = ? AND year = ?\",\n\t);\n\tconst selectEntryCache = db.prepare(\"SELECT id, ts_utc AS tsUtc FROM time_entries WHERE user_id = ?\");\n\tconst updateEntryCache = db.prepare(\"UPDATE time_entries SET ts_local = ?, local_date = ? WHERE id = ?\");\n\n\tconst timeZoneOf = (userId: number): string => users.findById(userId)?.timezone ?? \"UTC\";\n\tconst holidayRegion = (): string => settings.get(\"holiday_country\") ?? \"DE\";\n\n\t/**\n\t * How the pause of a day is determined.\n\t *\n\t * A stored value that is not one of the three modes falls back to the default, so a hand edited setting can\n\t * never spoil a calculation.\n\t *\n\t * @returns the configured pause mode\n\t */\n\tconst pauseMode = (): PauseMode => {\n\t\tconst stored = settings.get(\"pause_mode\");\n\t\treturn stored === \"punched\" || stored === \"staffel\" ? stored : \"auto\";\n\t};\n\n\t/**\n\t * Reads the stored profile or the defaults (no target time, no carryovers).\n\t *\n\t * @param userId - owner of the profile\n\t * @returns employment parameters\n\t */\n\tconst profileOf = (userId: number): WorkProfileRecord => users.getWorkProfile(userId) ?? defaultProfile(userId);\n\n\t/**\n\t * Picks the absence of a day: the one crediting the largest part of the day wins, ties are broken by id.\n\t *\n\t * @param userId - owner of the absence\n\t * @param date - local date\n\t * @returns the covering absence or `null`\n\t */\n\tconst absenceFor = (userId: number, date: string): AbsenceWithType | null => {\n\t\tconst covering = absences.withTypesInRange(userId, date, date);\n\t\tlet best: AbsenceWithType | null = null;\n\t\tfor (const absence of covering) {\n\t\t\tconst share = (Math.max(0, Math.min(100, absence.factor)) / 100) * absence.dayPortion;\n\t\t\tconst bestShare = best ? (Math.max(0, Math.min(100, best.factor)) / 100) * best.dayPortion : -1;\n\t\t\tif (share > bestShare || (share === bestShare && best !== null && absence.id < best.id)) {\n\t\t\t\tbest = absence;\n\t\t\t}\n\t\t}\n\t\treturn best;\n\t};\n\n\t/**\n\t * Sum of the payouts of a year.\n\t *\n\t * @param userId - owner of the payouts\n\t * @param year - four digit year\n\t * @returns paid out minutes\n\t */\n\tconst payoutsOf = (userId: number, year: number): number =>\n\t\t(selectPayouts.get(userId, year) as { minutes: number }).minutes;\n\n\t/**\n\t * Counts the months of a year that lie inside the employment period.\n\t *\n\t * @param profile - employment parameters\n\t * @param year - four digit year\n\t * @param upToMonth - last month to count (1 to 12)\n\t * @param timeZone - time zone used to resolve the employment dates\n\t * @returns number of active months\n\t */\n\tconst activeMonthsInYear = (profile: WorkProfile, year: number, upToMonth: number, timeZone: string): number => {\n\t\tconst startDate = profile.startDate == null ? null : resolveLocalDate(profile.startDate, timeZone);\n\t\tconst endDate = profile.endDate == null ? null : resolveLocalDate(profile.endDate, timeZone);\n\t\tconst limit = Math.max(0, Math.min(12, upToMonth));\n\t\tlet months = 0;\n\n\t\tfor (let month = 1; month <= limit; month++) {\n\t\t\tconst key = `${year}-${String(month).padStart(2, \"0\")}`;\n\t\t\tconst first = `${key}-01`;\n\t\t\tconst last = `${key}-${String(daysInMonth(year, month)).padStart(2, \"0\")}`;\n\t\t\tif ((startDate === null || last >= startDate) && (endDate === null || first <= endDate)) {\n\t\t\t\tmonths++;\n\t\t\t}\n\t\t}\n\t\treturn months;\n\t};\n\n\t/**\n\t * Overtime taken over at the beginning of a year: the previous year's result or the profile carryover.\n\t *\n\t * @param userId - owner\n\t * @param year - four digit year\n\t * @param carryover - carryover of the profile (used for the first year)\n\t * @returns overtime in minutes\n\t */\n\tconst overtimeStartFor = (userId: number, year: number, carryover: number): number => {\n\t\tconst previous = selectYear.get(userId, year - 1) as YearRow | undefined;\n\t\treturn previous ? previous.overtime_min : carryover;\n\t};\n\n\t/**\n\t * Overtime carried after a month, following the model of the profile.\n\t *\n\t * @param args - month, balances, profile and flags\n\t * @param args.userId - owner\n\t * @param args.year - four digit year\n\t * @param args.month - month, 1 to 12\n\t * @param args.monthBalance - freshly calculated balance of that month (`null` to read it from the database)\n\t * @param args.profile - employment parameters\n\t * @param args.timeZone - time zone of the employee\n\t * @param args.completeYear - true when the calendar year is finished\n\t * @returns carried overtime in minutes\n\t */\n\tconst overtimeAfterMonth = (args: {\n\t\tuserId: number;\n\t\tyear: number;\n\t\tmonth: number;\n\t\tmonthBalance: number | null;\n\t\tprofile: WorkProfileRecord;\n\t\ttimeZone: string;\n\t\tcompleteYear: boolean;\n\t}): number => {\n\t\tconst balances: number[] = [];\n\t\tfor (let index = 1; index <= args.month; index++) {\n\t\t\tif (index === args.month && args.monthBalance !== null) {\n\t\t\t\tbalances.push(args.monthBalance);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst row = selectMonth.get(args.userId, args.year, index) as MonthRow | undefined;\n\t\t\tbalances.push(row?.balance_min ?? 0);\n\t\t}\n\n\t\treturn overtimeAfterPeriod({\n\t\t\tmodel: args.profile.overtimeModel,\n\t\t\tmonthBalances: balances,\n\t\t\tovertimeStartMinutes: overtimeStartFor(args.userId, args.year, args.profile.overtimeCarryover),\n\t\t\tvorholzeitPerYearMinutes: args.profile.vorholzeitPerYear,\n\t\t\tactiveMonths: activeMonthsInYear(args.profile, args.year, args.month, args.timeZone),\n\t\t\tpayoutMinutes: payoutsOf(args.userId, args.year),\n\t\t\tcompleteYear: args.completeYear,\n\t\t}).overtimeMinutes;\n\t};\n\n\t/**\n\t * Vacation days of one kind inside a month or year.\n\t *\n\t * @param args - user, period and status\n\t * @param args.userId - owner\n\t * @param args.from - first local date\n\t * @param args.to - last local date\n\t * @param args.status - `taken` for used days, `planned` for approved days\n\t * @param args.profile - employment parameters (working days)\n\t * @param args.timeZone - time zone of the employee\n\t * @returns vacation days, rounded to two decimals\n\t */\n\tconst vacationDaysInRange = (args: {\n\t\tuserId: number;\n\t\tfrom: string;\n\t\tto: string;\n\t\tstatus: \"taken\" | \"planned\";\n\t\tprofile: WorkProfileRecord;\n\t\ttimeZone: string;\n\t}): number => {\n\t\tconst year = Number(args.to.slice(0, 4));\n\t\tconst holidayDates = holidays.dateSet(year, holidayRegion());\n\t\tconst overlapping = absences.withTypesInRange(args.userId, args.from, args.to);\n\n\t\tlet days = 0;\n\t\tfor (const absence of overlapping) {\n\t\t\tif (!absence.reduceVacation || absence.status !== args.status) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst from = absence.dateFrom < args.from ? args.from : absence.dateFrom;\n\t\t\tconst to = absence.dateTo > args.to ? args.to : absence.dateTo;\n\t\t\tif (from > to) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tdays += absenceDaysInRange({\n\t\t\t\tfrom,\n\t\t\t\tto,\n\t\t\t\tworkdays: args.profile.workdays,\n\t\t\t\ttimeZone: args.timeZone,\n\t\t\t\tholidays: holidayDates,\n\t\t\t\tdayPortion: absence.dayPortion,\n\t\t\t});\n\t\t}\n\t\treturn Math.round(days * 100) / 100;\n\t};\n\n\t/**\n\t * True when a calendar year is finished (needed by the `yearly` overtime model).\n\t *\n\t * @param year - four digit year\n\t * @param todayDate - local date of the user\n\t * @param settled - explicit flag of the caller\n\t * @returns true when the year is closed\n\t */\n\tconst isCompleteYear = (year: number, todayDate: string, settled?: boolean): boolean =>\n\t\tsettled === true || Number(todayDate.slice(0, 4)) > year;\n\n\t/**\n\t * Calculates one day from the stored data (profile, rules, holidays, absences, punches).\n\t *\n\t * @param userId - owner\n\t * @param localDate - local date to calculate\n\t * @param now - instant of the recalculation\n\t * @returns the calculated day\n\t */\n\tconst calculateDayFor = (userId: number, localDate: string, now: number): DayAggregateRecord => {\n\t\tconst timeZone = timeZoneOf(userId);\n\t\tconst absence = absenceFor(userId, localDate);\n\t\tconst isHoliday = holidays.isHoliday(localDate, holidayRegion());\n\t\tconst profile = profileOf(userId);\n\t\tconst day = calculateDay({\n\t\t\tprofile,\n\t\t\tentries: toPunchEntries(entries.listByDate(userId, localDate)),\n\t\t\tpauseRules: rules.pauseRules(userId),\n\t\t\tpauseMode: pauseMode(),\n\t\t\tpausePaidMinutes: profile.pausePaidMinutes,\n\t\t\ttimeZone,\n\t\t\tlocalDate,\n\t\t\tisHoliday,\n\t\t\tabsence: absence ? { factor: absence.factor, dayPortion: absence.dayPortion } : null,\n\t\t});\n\n\t\treturn {\n\t\t\tuserId,\n\t\t\tlocalDate,\n\t\t\tworkedMin: day.workedMinutes,\n\t\t\tbreakMin: day.breakMinutes,\n\t\t\tpaidBreakMin: day.paidPauseMinutes,\n\t\t\ttargetMin: day.targetMinutes,\n\t\t\tbalanceMin: day.balanceMinutes,\n\t\t\tabsenceCode: absence?.typeCode ?? null,\n\t\t\tisHoliday,\n\t\t\tfirstInUtc: day.firstInUtc,\n\t\t\tlastOutUtc: day.lastOutUtc,\n\t\t\thasOpenEntry: day.hasOpenEntry,\n\t\t\tupdatedAt: now,\n\t\t};\n\t};\n\n\treturn {\n\t\trecalculateDay(userId: number, localDate: string, options?: RecalculateOptions): DayAggregateRecord {\n\t\t\tconst now = options?.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst record = calculateDayFor(userId, localDate, now);\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupsertDay.run(\n\t\t\t\t\trecord.userId,\n\t\t\t\t\trecord.localDate,\n\t\t\t\t\trecord.workedMin,\n\t\t\t\t\trecord.breakMin,\n\t\t\t\t\trecord.paidBreakMin,\n\t\t\t\t\trecord.targetMin,\n\t\t\t\t\trecord.balanceMin,\n\t\t\t\t\trecord.absenceCode,\n\t\t\t\t\trecord.isHoliday ? 1 : 0,\n\t\t\t\t\trecord.firstInUtc,\n\t\t\t\t\trecord.lastOutUtc,\n\t\t\t\t\trecord.hasOpenEntry ? 1 : 0,\n\t\t\t\t\trecord.updatedAt,\n\t\t\t\t);\n\t\t\t});\n\t\t\trun();\n\t\t\treturn record;\n\t\t},\n\n\t\trecalculateRange(userId: number, from: string, to: string, options?: RecalculateOptions): RangeResult {\n\t\t\tconst totals: RangeResult = {\n\t\t\t\tdays: 0,\n\t\t\t\topenDays: 0,\n\t\t\t\tworkedMin: 0,\n\t\t\t\tbreakMin: 0,\n\t\t\t\tpaidBreakMin: 0,\n\t\t\t\ttargetMin: 0,\n\t\t\t\tbalanceMin: 0,\n\t\t\t};\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const date of dateRange(from, to)) {\n\t\t\t\t\tconst record = this.recalculateDay(userId, date, options);\n\t\t\t\t\ttotals.days++;\n\t\t\t\t\ttotals.workedMin += record.workedMin;\n\t\t\t\t\ttotals.breakMin += record.breakMin;\n\t\t\t\t\ttotals.paidBreakMin += record.paidBreakMin;\n\t\t\t\t\ttotals.targetMin += record.targetMin;\n\t\t\t\t\ttotals.balanceMin += record.balanceMin;\n\t\t\t\t\ttotals.openDays += record.hasOpenEntry ? 1 : 0;\n\t\t\t\t}\n\t\t\t});\n\t\t\trun();\n\t\t\treturn totals;\n\t\t},\n\n\t\trecalculateMonth(\n\t\t\tuserId: number,\n\t\t\tyear: number,\n\t\t\tmonth: number,\n\t\t\toptions?: RecalculateOptions,\n\t\t): MonthAggregateRecord {\n\t\t\tif (!Number.isInteger(month) || month < 1 || month > 12) {\n\t\t\t\tthrow new ValidationError(`month must be between 1 and 12 (got ${month})`);\n\t\t\t}\n\n\t\t\tconst now = options?.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst timeZone = timeZoneOf(userId);\n\t\t\tconst profile = profileOf(userId);\n\t\t\tconst upToDate = options?.upToDate ?? resolveLocalDate(now, timeZone);\n\t\t\tconst key = `${year}-${String(month).padStart(2, \"0\")}`;\n\t\t\tconst firstDay = `${key}-01`;\n\t\t\tconst lastDay = `${key}-${String(daysInMonth(year, month)).padStart(2, \"0\")}`;\n\t\t\t// days after `upToDate` are skipped unless the caller asks for them\n\t\t\tconst endDate = options?.includeFuture === true || lastDay <= upToDate ? lastDay : upToDate;\n\n\t\t\tlet workedMin = 0;\n\t\t\tlet targetMin = 0;\n\t\t\tlet balanceMin = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const date of dateRange(firstDay, endDate)) {\n\t\t\t\t\tconst record = this.recalculateDay(userId, date, options);\n\t\t\t\t\tworkedMin += record.workedMin;\n\t\t\t\t\ttargetMin += record.targetMin;\n\t\t\t\t\tbalanceMin += record.balanceMin;\n\t\t\t\t}\n\n\t\t\t\tconst vacationUsed = vacationDaysInRange({\n\t\t\t\t\tuserId,\n\t\t\t\t\tfrom: firstDay,\n\t\t\t\t\tto: lastDay,\n\t\t\t\t\tstatus: \"taken\",\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t});\n\t\t\t\tconst overtimeMin = overtimeAfterMonth({\n\t\t\t\t\tuserId,\n\t\t\t\t\tyear,\n\t\t\t\t\tmonth,\n\t\t\t\t\tmonthBalance: balanceMin,\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t\tcompleteYear: isCompleteYear(year, upToDate, options?.settled),\n\t\t\t\t});\n\n\t\t\t\tupsertMonth.run(userId, year, month, workedMin, targetMin, balanceMin, overtimeMin, vacationUsed, now);\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = selectMonth.get(userId, year, month) as MonthRow | undefined;\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`month aggregate ${key} disappeared right after the write`);\n\t\t\t}\n\t\t\treturn mapMonthRow(saved);\n\t\t},\n\n\t\trecalculateYear(userId: number, year: number, options?: RecalculateOptions): YearAggregateRecord {\n\t\t\tconst now = options?.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst timeZone = timeZoneOf(userId);\n\t\t\tconst profile = profileOf(userId);\n\t\t\tconst upToDate = options?.upToDate ?? resolveLocalDate(now, timeZone);\n\t\t\tconst settled = isCompleteYear(year, upToDate, options?.settled);\n\t\t\tconst yearStart = `${year}-01-01`;\n\t\t\tconst yearEnd = `${year}-12-31`;\n\n\t\t\tlet workedMin = 0;\n\t\t\tlet targetMin = 0;\n\t\t\tlet overtimeMin = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (let month = 1; month <= 12; month++) {\n\t\t\t\t\tconst row = this.recalculateMonth(userId, year, month, options);\n\t\t\t\t\tworkedMin += row.workedMin;\n\t\t\t\t\ttargetMin += row.targetMin;\n\t\t\t\t}\n\t\t\t\t// the carried overtime after December is the result of the year\n\t\t\t\tovertimeMin = overtimeAfterMonth({\n\t\t\t\t\tuserId,\n\t\t\t\t\tyear,\n\t\t\t\t\tmonth: 12,\n\t\t\t\t\tmonthBalance: null,\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t\tcompleteYear: settled,\n\t\t\t\t});\n\n\t\t\t\tconst vacationDays = profile.vacationCarryover + profile.vacationPerYear;\n\t\t\t\tconst vacationUsed = vacationDaysInRange({\n\t\t\t\t\tuserId,\n\t\t\t\t\tfrom: yearStart,\n\t\t\t\t\tto: yearEnd,\n\t\t\t\t\tstatus: \"taken\",\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t});\n\t\t\t\tconst vacationPlanned = vacationDaysInRange({\n\t\t\t\t\tuserId,\n\t\t\t\t\tfrom: yearStart,\n\t\t\t\t\tto: yearEnd,\n\t\t\t\t\tstatus: \"planned\",\n\t\t\t\t\tprofile,\n\t\t\t\t\ttimeZone,\n\t\t\t\t});\n\n\t\t\t\tupsertYear.run(\n\t\t\t\t\tuserId,\n\t\t\t\t\tyear,\n\t\t\t\t\tworkedMin,\n\t\t\t\t\ttargetMin,\n\t\t\t\t\tovertimeMin,\n\t\t\t\t\tovertimeStartFor(userId, year, profile.overtimeCarryover),\n\t\t\t\t\tMath.round(vacationDays * 100) / 100,\n\t\t\t\t\tvacationUsed,\n\t\t\t\t\tMath.round((vacationDays - vacationUsed) * 100) / 100,\n\t\t\t\t\tvacationPlanned,\n\t\t\t\t\tsettled ? 1 : 0,\n\t\t\t\t\tnow,\n\t\t\t\t);\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst saved = selectYear.get(userId, year) as YearRow | undefined;\n\t\t\tif (!saved) {\n\t\t\t\tthrow new Error(`year aggregate ${year} disappeared right after the write`);\n\t\t\t}\n\t\t\treturn mapYearRow(saved);\n\t\t},\n\n\t\tday(userId: number, localDate: string): DayAggregateRecord | null {\n\t\t\tconst row = selectDay.get(userId, localDate) as DayRow | undefined;\n\t\t\treturn row ? mapDayRow(row) : null;\n\t\t},\n\n\t\tdays(userId: number, from: string, to: string): DayAggregateRecord[] {\n\t\t\treturn (selectDays.all(userId, from, to) as DayRow[]).map(mapDayRow);\n\t\t},\n\n\t\tmonth(userId: number, year: number, month: number): MonthAggregateRecord | null {\n\t\t\tconst row = selectMonth.get(userId, year, month) as MonthRow | undefined;\n\t\t\treturn row ? mapMonthRow(row) : null;\n\t\t},\n\n\t\tyear(userId: number, year: number): YearAggregateRecord | null {\n\t\t\tconst row = selectYear.get(userId, year) as YearRow | undefined;\n\t\t\treturn row ? mapYearRow(row) : null;\n\t\t},\n\n\t\trecalculateLocalCaches(userId: number): number {\n\t\t\tconst timeZone = timeZoneOf(userId);\n\t\t\tconst rows = selectEntryCache.all(userId) as { id: number; tsUtc: number }[];\n\t\t\tlet changed = 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tfor (const row of rows) {\n\t\t\t\t\tconst tsLocal = utcToWallTime(row.tsUtc, timeZone);\n\t\t\t\t\tconst date = resolveLocalDate(row.tsUtc, timeZone);\n\t\t\t\t\tif (cacheMatches(db, row.id, tsLocal, date)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tupdateEntryCache.run(tsLocal, date, row.id);\n\t\t\t\t\tchanged++;\n\t\t\t\t}\n\t\t\t});\n\t\t\trun();\n\t\t\treturn changed;\n\t\t},\n\t};\n}\n\n/**\n * Checks whether the local cache fields of a punch already match.\n *\n * @param db - open database handle\n * @param entryId - punch id\n * @param tsLocal - expected wall clock time\n * @param localDate - expected local date\n * @returns true when both values are already stored\n */\nfunction cacheMatches(db: Db, entryId: number, tsLocal: number, localDate: string): boolean {\n\tconst row = db\n\t\t.prepare(\"SELECT ts_local AS tsLocal, local_date AS localDate FROM time_entries WHERE id = ?\")\n\t\t.get(entryId) as { tsLocal: number; localDate: string } | undefined;\n\treturn row !== undefined && row.tsLocal === tsLocal && row.localDate === localDate;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["resolveLocalDate"]
|
|
7
7
|
}
|
package/build/lib/web/api.js
CHANGED
|
@@ -192,7 +192,8 @@ const PROFILE_FIELDS = [
|
|
|
192
192
|
"vacationPerYear",
|
|
193
193
|
"overtimeModel",
|
|
194
194
|
"holidayFlags",
|
|
195
|
-
"pausePaidMinutes"
|
|
195
|
+
"pausePaidMinutes",
|
|
196
|
+
"showWorkedTime"
|
|
196
197
|
];
|
|
197
198
|
function readProfilePatch(body) {
|
|
198
199
|
const patch = {};
|
|
@@ -255,6 +256,12 @@ function readProfilePatch(body) {
|
|
|
255
256
|
continue;
|
|
256
257
|
}
|
|
257
258
|
patch.pausePaidMinutes = minutes;
|
|
259
|
+
} else if (key === "showWorkedTime") {
|
|
260
|
+
if (typeof value !== "boolean") {
|
|
261
|
+
issues.push({ path: key, message: "must be true or false" });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
patch.showWorkedTime = value;
|
|
258
265
|
} else {
|
|
259
266
|
const number = Number(value);
|
|
260
267
|
if (!Number.isInteger(number)) {
|
|
@@ -1953,11 +1960,14 @@ function createApi(deps) {
|
|
|
1953
1960
|
const visible = terminal.userIds.length === 0 ? candidates : candidates.filter((user) => terminal.userIds.includes(user.id));
|
|
1954
1961
|
return (0, import_router.json)(200, {
|
|
1955
1962
|
users: visible.map((user) => {
|
|
1956
|
-
var _a3;
|
|
1963
|
+
var _a3, _b2;
|
|
1964
|
+
const day = aggregation.day(user.id, (0, import_time.localDate)(timestamp, user.timezone));
|
|
1965
|
+
const showWorkedTime = ((_a3 = users.getWorkProfile(user.id)) == null ? void 0 : _a3.showWorkedTime) === true;
|
|
1957
1966
|
return {
|
|
1958
1967
|
id: user.id,
|
|
1959
1968
|
displayName: user.displayName,
|
|
1960
|
-
present: (
|
|
1969
|
+
present: (day == null ? void 0 : day.hasOpenEntry) === true,
|
|
1970
|
+
workedMin: showWorkedTime ? (_b2 = day == null ? void 0 : day.workedMin) != null ? _b2 : 0 : void 0,
|
|
1961
1971
|
// the picture is fetched by the browser from its own route; the session of this device travels
|
|
1962
1972
|
// in the query, so a plain `<img>` can load it
|
|
1963
1973
|
avatarUrl: user.avatar ? `/api/users/${user.id}/avatar?terminalSession=${encodeURIComponent(terminalSession)}&v=${user.updatedAt}` : null
|
|
@@ -1971,7 +1981,7 @@ function createApi(deps) {
|
|
|
1971
1981
|
"/terminal/punch",
|
|
1972
1982
|
{ public: true, csrf: false, rateLimit: { name: "terminal-punch", limit: 60, windowSeconds: 60 } },
|
|
1973
1983
|
(context) => {
|
|
1974
|
-
var _a2, _b2;
|
|
1984
|
+
var _a2, _b2, _c2;
|
|
1975
1985
|
const terminal = requireTerminal(context);
|
|
1976
1986
|
const body = context.jsonBody();
|
|
1977
1987
|
const badge = optionalString(body, "badge");
|
|
@@ -2030,7 +2040,9 @@ function createApi(deps) {
|
|
|
2030
2040
|
localDate: stored.entry.localDate
|
|
2031
2041
|
},
|
|
2032
2042
|
day: {
|
|
2033
|
-
|
|
2043
|
+
// the worked time of the day only when the work profile of the employee allows it: the
|
|
2044
|
+
// presence card is public, so the same rule as in `/terminal/users` applies here
|
|
2045
|
+
workedMin: ((_c2 = users.getWorkProfile(user.id)) == null ? void 0 : _c2.showWorkedTime) === true ? day.workedMin : void 0,
|
|
2034
2046
|
targetMin: day.targetMin,
|
|
2035
2047
|
balanceMin: day.balanceMin,
|
|
2036
2048
|
hasOpenEntry: day.hasOpenEntry
|