iobroker.time-tracker 0.3.4 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/lib/db/repositories/absences.ts"],
4
- "sourcesContent": ["/**\n * Absence repository (`absence_types`, `absences`).\n *\n * An absence is a date range plus a day portion, so half days and multi-day absences share one\n * representation. The status separates requested (`planned`) from booked (`taken`) absences; the\n * vacation balance reports used and planned days separately.\n */\n\nimport type { Db } from \"../database\";\nimport { isDateString } from \"../../util/time\";\nimport { NotFoundError, ValidationError } from \"../../errors\";\nimport { diffFields, writeAuditLog } from \"./audit\";\n\n/** Status of an absence: requested/planned or already taken. */\nexport type AbsenceStatus = \"taken\" | \"planned\";\n\n/** An absence type (global when `userId` is `null`, otherwise user specific). */\nexport interface AbsenceTypeRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Owner, `null` for the global types */\n\tuserId: number | null;\n\t/** Short code, e.g. `F` for vacation */\n\tcode: string;\n\t/** Display name */\n\tname: string;\n\t/** True when the absence is paid */\n\tpaid: boolean;\n\t/** Percentage of the working time credited (100 = fully credited) */\n\tfactor: number;\n\t/** True for vacation, which reduces the vacation balance */\n\treduceVacation: boolean;\n\t/** Inactive types are hidden but keep their history */\n\tisActive: boolean;\n}\n\n/** Input for creating or updating an absence type. */\nexport interface UpsertAbsenceTypeInput {\n\t/** Owner, `null` (default) for the global types */\n\tuserId?: number | null;\n\t/** Short code, e.g. `F` for vacation */\n\tcode: string;\n\t/** Display name */\n\tname: string;\n\t/** True when the absence is paid, defaults to `true` */\n\tpaid?: boolean;\n\t/** Percentage of the working time credited (defaults to `100`) */\n\tfactor?: number;\n\t/** True for vacation, which reduces the vacation balance, defaults to `false` */\n\treduceVacation?: boolean;\n\t/** False hides the type but keeps its history, defaults to `true` */\n\tisActive?: boolean;\n\t/** Who creates or changes the type */\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/** An absence as stored in the database. */\nexport interface AbsenceRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Employee the absence belongs to */\n\tuserId: number;\n\t/** Absence type */\n\ttypeId: number;\n\t/** First day, local date `YYYY-MM-DD` */\n\tdateFrom: string;\n\t/** Last day, local date `YYYY-MM-DD` */\n\tdateTo: string;\n\t/** Portion of each day (1 = whole day, 0.5 = half day) */\n\tdayPortion: number;\n\t/** Hours of a partial day absence, informational only */\n\thours: number | null;\n\t/** Requested or already taken */\n\tstatus: AbsenceStatus;\n\t/** Free-form note */\n\tnote: string | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n\t/** Who created the absence */\n\tcreatedBy: number | null;\n}\n\n/** An absence together with its type (input for the calculation service). */\nexport interface AbsenceWithType extends AbsenceRecord {\n\t/** Short code of the type, e.g. `F` */\n\ttypeCode: string;\n\t/** Display name of the type */\n\ttypeName: string;\n\t/** True when the absence is paid */\n\tpaid: boolean;\n\t/** Percentage of the working time credited */\n\tfactor: number;\n\t/** True for vacation, which reduces the vacation balance */\n\treduceVacation: boolean;\n}\n\n/** Input for creating an absence. */\nexport interface CreateAbsenceInput {\n\t/** Employee the absence belongs to */\n\tuserId: number;\n\t/** Absence type by id (alternative to `typeCode`) */\n\ttypeId?: number;\n\t/** Absence type by code, user specific types win over the global ones */\n\ttypeCode?: string;\n\t/** First day, local date `YYYY-MM-DD` */\n\tdateFrom: string;\n\t/** Last day, defaults to `dateFrom` */\n\tdateTo?: string;\n\t/** Portion of each day, default 1 */\n\tdayPortion?: number;\n\t/** Hours of a partial day absence */\n\thours?: number | null;\n\t/** Status, default `planned` (a request that still needs approval) */\n\tstatus?: AbsenceStatus;\n\t/** Free-form note */\n\tnote?: string | null;\n\t/** Who creates the absence */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Instant of creation, defaults to now */\n\tnow?: number;\n}\n\n/** Input for changing an absence. */\nexport interface UpdateAbsenceInput {\n\t/** Id of the changed absence */\n\tid: number;\n\t/** Fields to change */\n\tpatch: {\n\t\ttypeId?: number;\n\t\ttypeCode?: string;\n\t\tdateFrom?: string;\n\t\tdateTo?: string;\n\t\tdayPortion?: number;\n\t\thours?: number | null;\n\t\tnote?: string | null;\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 an absence type cannot be resolved. */\nexport class UnknownAbsenceTypeError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param reference - id or code that could not be resolved\n\t */\n\tconstructor(public readonly reference: string | number) {\n\t\tsuper(`unknown absence type \"${reference}\"`);\n\t\tthis.name = \"UnknownAbsenceTypeError\";\n\t}\n}\n\n/** Absence storage operations. */\nexport interface AbsencesRepository {\n\t/** Absence types of a user, including the global ones */\n\ttypes(options?: { userId?: number | null; includeInactive?: boolean }): AbsenceTypeRecord[];\n\t/** Resolves a type by id or code, user specific types win over the global ones */\n\tfindType(reference: number | string, userId?: number | null): AbsenceTypeRecord | null;\n\t/** Creates an absence type or updates the existing one with the same code */\n\tupsertType(input: UpsertAbsenceTypeInput): { type: AbsenceTypeRecord; created: boolean };\n\t/** Reads an absence by id */\n\tfindById(id: number): AbsenceRecord | null;\n\t/** Creates an absence and audits it */\n\tcreate(input: CreateAbsenceInput): AbsenceRecord;\n\t/** Changes an absence and audits the changed fields */\n\tupdate(input: UpdateAbsenceInput): AbsenceRecord;\n\t/** Moves an absence between `planned` and `taken` */\n\tsetStatus(input: {\n\t\tid: number;\n\t\tstatus: AbsenceStatus;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): AbsenceRecord;\n\t/** Deletes an absence by id */\n\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n\t/** Absences of one user within an optional period, sorted by start date */\n\tlistByUser(userId: number, options?: { from?: string; to?: string; year?: number }): AbsenceRecord[];\n\t/** Absences overlapping a date, sorted by start date */\n\tforDate(userId: number, date: string): AbsenceRecord[];\n\t/** Absences overlapping a range, including their type information */\n\twithTypesInRange(userId: number, from: string, to: string): AbsenceWithType[];\n}\n\ninterface AbsenceTypeRow {\n\tid: number;\n\tuser_id: number | null;\n\tcode: string;\n\tname: string;\n\tpaid: number;\n\tfactor: number;\n\treduce_vacation: number;\n\tis_active: number;\n}\n\ninterface AbsenceRow {\n\tid: number;\n\tuser_id: number;\n\ttype_id: number;\n\tdate_from: string;\n\tdate_to: string;\n\tday_portion: number;\n\thours: number | null;\n\tstatus: AbsenceStatus;\n\tnote: string | null;\n\tcreated_at: number;\n\tcreated_by: number | null;\n}\n\ninterface AbsenceWithTypeRow extends AbsenceRow {\n\ttype_code: string;\n\ttype_name: string;\n\ttype_paid: number;\n\ttype_factor: number;\n\ttype_reduce_vacation: number;\n}\n\nconst TYPE_COLUMNS = \"id, user_id, code, name, paid, factor, reduce_vacation, is_active\";\nconst ABSENCE_COLUMNS = `id, user_id, type_id, date_from, date_to, day_portion, hours, status, note,\n\\tcreated_at, created_by`;\n\n/** Field names of an absence that are compared for the audit trail. */\nconst AUDITED_FIELDS: (keyof AbsenceRecord)[] = [\"typeId\", \"dateFrom\", \"dateTo\", \"dayPortion\", \"hours\", \"note\"];\n\n/**\n * Maps a database row to an absence type record.\n *\n * @param row - raw database row\n * @returns absence type record\n */\nexport function mapAbsenceTypeRow(row: AbsenceTypeRow): AbsenceTypeRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuserId: row.user_id,\n\t\tcode: row.code,\n\t\tname: row.name,\n\t\tpaid: row.paid !== 0,\n\t\tfactor: row.factor,\n\t\treduceVacation: row.reduce_vacation !== 0,\n\t\tisActive: row.is_active !== 0,\n\t};\n}\n\n/**\n * Maps a database row to an absence record.\n *\n * @param row - raw database row\n * @returns absence record\n */\nexport function mapAbsenceRow(row: AbsenceRow): AbsenceRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuserId: row.user_id,\n\t\ttypeId: row.type_id,\n\t\tdateFrom: row.date_from,\n\t\tdateTo: row.date_to,\n\t\tdayPortion: row.day_portion,\n\t\thours: row.hours,\n\t\tstatus: row.status,\n\t\tnote: row.note,\n\t\tcreatedAt: row.created_at,\n\t\tcreatedBy: row.created_by,\n\t};\n}\n\n/**\n * Checks and normalises a calendar date.\n *\n * @param value - date to validate\n * @param label - field name used in the error message\n * @returns the validated date\n */\nfunction requireDate(value: string, label: string): string {\n\tconst trimmed = value.trim();\n\tif (!isDateString(trimmed)) {\n\t\tthrow new ValidationError(`${label} \"${value}\" is not a date (YYYY-MM-DD)`);\n\t}\n\treturn trimmed;\n}\n\n/**\n * Validates the day portion of an absence.\n *\n * @param value - raw day portion\n * @returns the validated day portion\n */\nfunction requireDayPortion(value: number): number {\n\tif (!Number.isFinite(value) || value <= 0 || value > 1) {\n\t\tthrow new ValidationError(`dayPortion must be greater than 0 and at most 1 (got ${value})`);\n\t}\n\treturn value;\n}\n\n/**\n * Creates the absence repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createAbsencesRepository(db: Db): AbsencesRepository {\n\tconst selectTypeById = db.prepare(`SELECT ${TYPE_COLUMNS} FROM absence_types WHERE id = ?`);\n\tconst selectTypeUserCode = db.prepare(`SELECT ${TYPE_COLUMNS} FROM absence_types WHERE code = ? AND user_id = ?`);\n\tconst selectTypeGlobalCode = db.prepare(\n\t\t`SELECT ${TYPE_COLUMNS} FROM absence_types WHERE code = ? AND user_id IS NULL`,\n\t);\n\tconst selectAbsenceById = db.prepare(`SELECT ${ABSENCE_COLUMNS} FROM absences WHERE id = ?`);\n\tconst selectByUser = db.prepare(\n\t\t`SELECT ${ABSENCE_COLUMNS} FROM absences\n\t\t WHERE user_id = ? AND date_to >= ? AND date_from <= ?\n\t\t ORDER BY date_from, date_to, id`,\n\t);\n\tconst selectWithTypes = db.prepare(\n\t\t`SELECT a.id, a.user_id, a.type_id, a.date_from, a.date_to, a.day_portion, a.hours, a.status, a.note,\n\t\t a.created_at, a.created_by,\n\t\t t.code AS type_code, t.name AS type_name, t.paid AS type_paid, t.factor AS type_factor,\n\t\t t.reduce_vacation AS type_reduce_vacation\n\t\t FROM absences a JOIN absence_types t ON t.id = a.type_id\n\t\t WHERE a.user_id = ? AND a.date_to >= ? AND a.date_from <= ?\n\t\t ORDER BY a.date_from, a.date_to, a.id`,\n\t);\n\tconst insertAbsence = db.prepare(\n\t\t`INSERT INTO absences\n\t\t (user_id, type_id, date_from, date_to, day_portion, hours, status, note, created_at, created_by)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateAbsence = db.prepare(\n\t\t`UPDATE absences SET type_id = ?, date_from = ?, date_to = ?, day_portion = ?, hours = ?, note = ?\n\t\t WHERE id = ?`,\n\t);\n\tconst updateStatusStatement = db.prepare(\"UPDATE absences SET status = ? WHERE id = ?\");\n\tconst deleteAbsence = db.prepare(\"DELETE FROM absences WHERE id = ?\");\n\tconst insertType = db.prepare(\n\t\t`INSERT INTO absence_types (user_id, code, name, paid, factor, reduce_vacation, is_active)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateType = db.prepare(\n\t\t`UPDATE absence_types SET name = ?, paid = ?, factor = ?, reduce_vacation = ?, is_active = ? WHERE id = ?`,\n\t);\n\n\tconst read = (id: number): AbsenceRecord | null => {\n\t\tconst row = selectAbsenceById.get(id) as AbsenceRow | undefined;\n\t\treturn row ? mapAbsenceRow(row) : null;\n\t};\n\n\t/**\n\t * Resolves a type by id or code (user specific types win over the global ones).\n\t *\n\t * @param reference - id or code\n\t * @param userId - owner used to prefer user specific types\n\t * @returns type record or `null`\n\t */\n\tconst resolveType = (reference: number | string, userId?: number | null): AbsenceTypeRecord | null => {\n\t\tif (typeof reference === \"number\") {\n\t\t\tconst row = selectTypeById.get(reference) as AbsenceTypeRow | undefined;\n\t\t\treturn row ? mapAbsenceTypeRow(row) : null;\n\t\t}\n\t\tif (userId != null) {\n\t\t\tconst own = selectTypeUserCode.get(reference, userId) as AbsenceTypeRow | undefined;\n\t\t\tif (own) {\n\t\t\t\treturn mapAbsenceTypeRow(own);\n\t\t\t}\n\t\t}\n\t\tconst global = selectTypeGlobalCode.get(reference) as AbsenceTypeRow | undefined;\n\t\treturn global ? mapAbsenceTypeRow(global) : null;\n\t};\n\n\t/**\n\t * Resolves a type or throws when it cannot be found.\n\t *\n\t * @param input - type reference and owner\n\t * @param input.typeId - type id (alternative to `input.typeCode`)\n\t * @param input.typeCode - type code (alternative to `input.typeId`)\n\t * @param input.userId - owner used to prefer user specific types\n\t * @returns resolved type\n\t */\n\tconst requireType = (input: { typeId?: number; typeCode?: string; userId: number }): AbsenceTypeRecord => {\n\t\tconst reference = input.typeId ?? input.typeCode;\n\t\tif (reference === undefined) {\n\t\t\tthrow new UnknownAbsenceTypeError(\"(none)\");\n\t\t}\n\t\tconst type = resolveType(reference, input.userId);\n\t\tif (!type) {\n\t\t\tthrow new UnknownAbsenceTypeError(reference);\n\t\t}\n\t\treturn type;\n\t};\n\n\treturn {\n\t\ttypes(options?: { userId?: number | null; includeInactive?: boolean }): AbsenceTypeRecord[] {\n\t\t\tconst activeOnly = options?.includeInactive === true ? \"\" : \" AND is_active = 1\";\n\t\t\tconst userId = options?.userId ?? null;\n\t\t\tconst rows = db\n\t\t\t\t.prepare(\n\t\t\t\t\t`SELECT ${TYPE_COLUMNS} FROM absence_types\n\t\t\t\t\t WHERE (user_id IS NULL OR user_id = ?)${activeOnly}\n\t\t\t\t\t ORDER BY code, id`,\n\t\t\t\t)\n\t\t\t\t.all(userId) as AbsenceTypeRow[];\n\t\t\treturn rows.map(mapAbsenceTypeRow);\n\t\t},\n\n\t\tfindType(reference: number | string, userId?: number | null): AbsenceTypeRecord | null {\n\t\t\treturn resolveType(reference, userId);\n\t\t},\n\n\t\tupsertType(input: UpsertAbsenceTypeInput): { type: AbsenceTypeRecord; created: boolean } {\n\t\t\tconst userId = input.userId ?? null;\n\t\t\tconst code = input.code.trim().toUpperCase();\n\t\t\tif (!/^[A-Za-z0-9_-]{1,8}$/.test(code)) {\n\t\t\t\tthrow new ValidationError(`code must be 1 to 8 letters, digits, \"-\" or \"_\" (got \"${input.code}\")`);\n\t\t\t}\n\t\t\tconst name = input.name.trim();\n\t\t\tif (!name) {\n\t\t\t\tthrow new ValidationError(\"name is required\");\n\t\t\t}\n\t\t\tconst factor = input.factor ?? 100;\n\t\t\tif (!Number.isInteger(factor) || factor < 0 || factor > 100) {\n\t\t\t\tthrow new ValidationError(`factor must be a whole number between 0 and 100 (got ${input.factor})`);\n\t\t\t}\n\t\t\tconst paid = input.paid ?? true;\n\t\t\tconst reduceVacation = input.reduceVacation ?? false;\n\t\t\tconst isActive = input.isActive ?? true;\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tconst existing = resolveType(code, userId);\n\t\t\tlet typeId = existing?.id ?? 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tif (existing) {\n\t\t\t\t\tupdateType.run(name, paid ? 1 : 0, factor, reduceVacation ? 1 : 0, isActive ? 1 : 0, existing.id);\n\t\t\t\t} else {\n\t\t\t\t\tconst result = insertType.run(\n\t\t\t\t\t\tuserId,\n\t\t\t\t\t\tcode,\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tpaid ? 1 : 0,\n\t\t\t\t\t\tfactor,\n\t\t\t\t\t\treduceVacation ? 1 : 0,\n\t\t\t\t\t\tisActive ? 1 : 0,\n\t\t\t\t\t);\n\t\t\t\t\ttypeId = Number(result.lastInsertRowid);\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\tip: input.actorIp ?? null,\n\t\t\t\t\taction: existing ? \"absence_type.update\" : \"absence_type.create\",\n\t\t\t\t\tentity: \"absence_types\",\n\t\t\t\t\tentityId: typeId,\n\t\t\t\t\tdetail: { code, userId, name, paid, factor, reduceVacation, isActive },\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst type = existing\n\t\t\t\t? { ...existing, name, paid, factor, reduceVacation, isActive }\n\t\t\t\t: {\n\t\t\t\t\t\tid: typeId,\n\t\t\t\t\t\tuserId,\n\t\t\t\t\t\tcode,\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tpaid,\n\t\t\t\t\t\tfactor,\n\t\t\t\t\t\treduceVacation,\n\t\t\t\t\t\tisActive,\n\t\t\t\t\t};\n\t\t\treturn { type, created: existing === null };\n\t\t},\n\n\t\tfindById: read,\n\n\t\tcreate(input: CreateAbsenceInput): AbsenceRecord {\n\t\t\tconst type = requireType(input);\n\t\t\tconst dateFrom = requireDate(input.dateFrom, \"dateFrom\");\n\t\t\tconst dateTo = requireDate(input.dateTo ?? dateFrom, \"dateTo\");\n\t\t\tif (dateFrom > dateTo) {\n\t\t\t\tthrow new ValidationError(`dateFrom (${dateFrom}) must not be after dateTo (${dateTo})`);\n\t\t\t}\n\t\t\tconst dayPortion = requireDayPortion(input.dayPortion ?? 1);\n\t\t\tconst status: AbsenceStatus = input.status ?? \"planned\";\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tlet absenceId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertAbsence.run(\n\t\t\t\t\tinput.userId,\n\t\t\t\t\ttype.id,\n\t\t\t\t\tdateFrom,\n\t\t\t\t\tdateTo,\n\t\t\t\t\tdayPortion,\n\t\t\t\t\tinput.hours ?? null,\n\t\t\t\t\tstatus,\n\t\t\t\t\tinput.note ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t\tinput.actorId,\n\t\t\t\t);\n\t\t\t\tabsenceId = Number(result.lastInsertRowid);\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: \"absence.create\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: absenceId,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\t\ttypeCode: type.code,\n\t\t\t\t\t\tdateFrom,\n\t\t\t\t\t\tdateTo,\n\t\t\t\t\t\tdayPortion,\n\t\t\t\t\t\tstatus,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst created = read(absenceId);\n\t\t\tif (!created) {\n\t\t\t\tthrow new Error(`absence ${absenceId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn created;\n\t\t},\n\n\t\tupdate(input: UpdateAbsenceInput): AbsenceRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`absence ${input.id} not found`);\n\t\t\t}\n\n\t\t\tconst type =\n\t\t\t\tinput.patch.typeId === undefined && input.patch.typeCode === undefined\n\t\t\t\t\t? null\n\t\t\t\t\t: requireType({ ...input.patch, userId: current.userId });\n\t\t\tconst next: AbsenceRecord = {\n\t\t\t\t...current,\n\t\t\t\ttypeId: type?.id ?? current.typeId,\n\t\t\t\tdateFrom: input.patch.dateFrom ? requireDate(input.patch.dateFrom, \"dateFrom\") : current.dateFrom,\n\t\t\t\tdateTo: input.patch.dateTo ? requireDate(input.patch.dateTo, \"dateTo\") : current.dateTo,\n\t\t\t\tdayPortion:\n\t\t\t\t\tinput.patch.dayPortion === undefined\n\t\t\t\t\t\t? current.dayPortion\n\t\t\t\t\t\t: requireDayPortion(input.patch.dayPortion),\n\t\t\t\thours: input.patch.hours === undefined ? current.hours : input.patch.hours,\n\t\t\t\tnote: input.patch.note === undefined ? current.note : input.patch.note,\n\t\t\t};\n\t\t\tif (next.dateFrom > next.dateTo) {\n\t\t\t\tthrow new ValidationError(`dateFrom (${next.dateFrom}) must not be after dateTo (${next.dateTo})`);\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_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tif (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\tupdateAbsence.run(\n\t\t\t\t\tnext.typeId,\n\t\t\t\t\tnext.dateFrom,\n\t\t\t\t\tnext.dateTo,\n\t\t\t\t\tnext.dayPortion,\n\t\t\t\t\tnext.hours,\n\t\t\t\t\tnext.note,\n\t\t\t\t\tinput.id,\n\t\t\t\t);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"absence.update\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { changes, 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(`absence ${input.id} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\tsetStatus(input: {\n\t\t\tid: number;\n\t\t\tstatus: AbsenceStatus;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): AbsenceRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`absence ${input.id} not found`);\n\t\t\t}\n\t\t\tif (current.status === input.status) {\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\tupdateStatusStatement.run(input.status, input.id);\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: \"absence.status\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { changes: { status: { old: current.status, new: input.status } } },\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(`absence ${input.id} disappeared right after the status change`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeleteAbsence.run(input.id);\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: \"absence.delete\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: current.userId,\n\t\t\t\t\t\tdateFrom: current.dateFrom,\n\t\t\t\t\t\tdateTo: current.dateTo,\n\t\t\t\t\t\tstatus: current.status,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\n\t\tlistByUser(userId: number, options?: { from?: string; to?: string; year?: number }): AbsenceRecord[] {\n\t\t\tconst from = options?.from ?? (options?.year === undefined ? \"0000-01-01\" : `${options.year}-01-01`);\n\t\t\tconst to = options?.to ?? (options?.year === undefined ? \"9999-12-31\" : `${options.year}-12-31`);\n\t\t\tconst rows = selectByUser.all(userId, from, to) as AbsenceRow[];\n\t\t\treturn rows.map(mapAbsenceRow);\n\t\t},\n\n\t\tforDate(userId: number, date: string): AbsenceRecord[] {\n\t\t\tconst day = requireDate(date, \"date\");\n\t\t\tconst rows = selectByUser.all(userId, day, day) as AbsenceRow[];\n\t\t\treturn rows.map(mapAbsenceRow);\n\t\t},\n\n\t\twithTypesInRange(userId: number, from: string, to: string): AbsenceWithType[] {\n\t\t\tconst rows = selectWithTypes.all(\n\t\t\t\tuserId,\n\t\t\t\trequireDate(from, \"from\"),\n\t\t\t\trequireDate(to, \"to\"),\n\t\t\t) as AbsenceWithTypeRow[];\n\t\t\treturn rows.map(row => ({\n\t\t\t\t...mapAbsenceRow(row),\n\t\t\t\ttypeCode: row.type_code,\n\t\t\t\ttypeName: row.type_name,\n\t\t\t\tpaid: row.type_paid !== 0,\n\t\t\t\tfactor: row.type_factor,\n\t\t\t\treduceVacation: row.type_reduce_vacation !== 0,\n\t\t\t}));\n\t\t},\n\t};\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Absence repository (`absence_types`, `absences`).\n *\n * An absence is a date range plus a day portion, so half days and multi-day absences share one\n * representation. The status separates requested (`planned`) from booked (`taken`) absences; the\n * vacation balance reports used and planned days separately.\n */\n\nimport type { Db } from \"../database\";\nimport { isDateString } from \"../../util/time\";\nimport { NotFoundError, ValidationError } from \"../../errors\";\nimport { diffFields, writeAuditLog } from \"./audit\";\n\n/** Status of an absence: requested/planned or already taken. */\nexport type AbsenceStatus = \"taken\" | \"planned\";\n\n/**\n * Approval of an absence: the employee requests, the administration decides.\n *\n * Independent of {@link AbsenceStatus}, which says whether the days are planned or already taken \u2014 a request can be\n * planned and still wait for its decision. Rows that exist before migration 21 count as `approved`, so nothing\n * changes for a running installation.\n */\nexport type AbsenceApproval = \"requested\" | \"approved\" | \"rejected\";\n\n/**\n * True when an absence counts for the calculation.\n *\n * A row counts unless it is a request that still waits for its decision or a rejected one. Everything else is\n * effective \u2014 including rows that predate migration 21 and therefore carry no value at all.\n *\n * @param absence - the absence to check\n * @param absence.approval - approval of the absence, missing on rows that predate migration 21\n * @returns true when the days count\n */\nexport function isApproved(absence: { approval?: AbsenceApproval }): boolean {\n\treturn absence.approval !== \"requested\" && absence.approval !== \"rejected\";\n}\n\n/** An absence type (global when `userId` is `null`, otherwise user specific). */\nexport interface AbsenceTypeRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Owner, `null` for the global types */\n\tuserId: number | null;\n\t/** Short code, e.g. `F` for vacation */\n\tcode: string;\n\t/** Display name */\n\tname: string;\n\t/** True when the absence is paid */\n\tpaid: boolean;\n\t/** Percentage of the working time credited (100 = fully credited) */\n\tfactor: number;\n\t/** True for vacation, which reduces the vacation balance */\n\treduceVacation: boolean;\n\t/** Inactive types are hidden but keep their history */\n\tisActive: boolean;\n}\n\n/** Input for creating or updating an absence type. */\nexport interface UpsertAbsenceTypeInput {\n\t/** Owner, `null` (default) for the global types */\n\tuserId?: number | null;\n\t/** Short code, e.g. `F` for vacation */\n\tcode: string;\n\t/** Display name */\n\tname: string;\n\t/** True when the absence is paid, defaults to `true` */\n\tpaid?: boolean;\n\t/** Percentage of the working time credited (defaults to `100`) */\n\tfactor?: number;\n\t/** True for vacation, which reduces the vacation balance, defaults to `false` */\n\treduceVacation?: boolean;\n\t/** False hides the type but keeps its history, defaults to `true` */\n\tisActive?: boolean;\n\t/** Who creates or changes the type */\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/** An absence as stored in the database. */\nexport interface AbsenceRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Employee the absence belongs to */\n\tuserId: number;\n\t/** Absence type */\n\ttypeId: number;\n\t/** First day, local date `YYYY-MM-DD` */\n\tdateFrom: string;\n\t/** Last day, local date `YYYY-MM-DD` */\n\tdateTo: string;\n\t/** Portion of each day (1 = whole day, 0.5 = half day) */\n\tdayPortion: number;\n\t/** Hours of a partial day absence, informational only */\n\thours: number | null;\n\t/** Requested or already taken */\n\tstatus: AbsenceStatus;\n\t/** Approval of the administration: requested, approved or rejected */\n\tapproval: AbsenceApproval;\n\t/** Instant of the decision, UTC epoch seconds (`null` while nobody decided) */\n\tdecidedAt: number | null;\n\t/** Who decided about the request */\n\tdecidedBy: number | null;\n\t/** Reason of the decision, shown to the employee */\n\tdecisionNote: string | null;\n\t/** Free-form note */\n\tnote: string | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n\t/** Who created the absence */\n\tcreatedBy: number | null;\n}\n\n/** An absence together with its type (input for the calculation service). */\nexport interface AbsenceWithType extends AbsenceRecord {\n\t/** Short code of the type, e.g. `F` */\n\ttypeCode: string;\n\t/** Display name of the type */\n\ttypeName: string;\n\t/** True when the absence is paid */\n\tpaid: boolean;\n\t/** Percentage of the working time credited */\n\tfactor: number;\n\t/** True for vacation, which reduces the vacation balance */\n\treduceVacation: boolean;\n}\n\n/** Input for creating an absence. */\nexport interface CreateAbsenceInput {\n\t/** Employee the absence belongs to */\n\tuserId: number;\n\t/** Absence type by id (alternative to `typeCode`) */\n\ttypeId?: number;\n\t/** Absence type by code, user specific types win over the global ones */\n\ttypeCode?: string;\n\t/** First day, local date `YYYY-MM-DD` */\n\tdateFrom: string;\n\t/** Last day, defaults to `dateFrom` */\n\tdateTo?: string;\n\t/** Portion of each day, default 1 */\n\tdayPortion?: number;\n\t/** Hours of a partial day absence */\n\thours?: number | null;\n\t/** Status, default `planned` (a request that still needs approval) */\n\tstatus?: AbsenceStatus;\n\t/** Approval, default `approved` (the administration enters dates, an employee requests them) */\n\tapproval?: AbsenceApproval;\n\t/** Free-form note */\n\tnote?: string | null;\n\t/** Who creates the absence */\n\tactorId: number;\n\t/** Client IP address of the actor */\n\tactorIp?: string | null;\n\t/** Instant of creation, defaults to now */\n\tnow?: number;\n}\n\n/** Input for changing an absence. */\nexport interface UpdateAbsenceInput {\n\t/** Id of the changed absence */\n\tid: number;\n\t/** Fields to change */\n\tpatch: {\n\t\ttypeId?: number;\n\t\ttypeCode?: string;\n\t\tdateFrom?: string;\n\t\tdateTo?: string;\n\t\tdayPortion?: number;\n\t\thours?: number | null;\n\t\tnote?: string | null;\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 an absence type cannot be resolved. */\nexport class UnknownAbsenceTypeError extends Error {\n\t/**\n\t * Creates the error.\n\t *\n\t * @param reference - id or code that could not be resolved\n\t */\n\tconstructor(public readonly reference: string | number) {\n\t\tsuper(`unknown absence type \"${reference}\"`);\n\t\tthis.name = \"UnknownAbsenceTypeError\";\n\t}\n}\n\n/** Absence storage operations. */\nexport interface AbsencesRepository {\n\t/** Absence types of a user, including the global ones */\n\ttypes(options?: { userId?: number | null; includeInactive?: boolean }): AbsenceTypeRecord[];\n\t/** Resolves a type by id or code, user specific types win over the global ones */\n\tfindType(reference: number | string, userId?: number | null): AbsenceTypeRecord | null;\n\t/** Creates an absence type or updates the existing one with the same code */\n\tupsertType(input: UpsertAbsenceTypeInput): { type: AbsenceTypeRecord; created: boolean };\n\t/** Reads an absence by id */\n\tfindById(id: number): AbsenceRecord | null;\n\t/** Creates an absence and audits it */\n\tcreate(input: CreateAbsenceInput): AbsenceRecord;\n\t/** Changes an absence and audits the changed fields */\n\tupdate(input: UpdateAbsenceInput): AbsenceRecord;\n\t/** Moves an absence between `planned` and `taken` */\n\tsetStatus(input: {\n\t\tid: number;\n\t\tstatus: AbsenceStatus;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): AbsenceRecord;\n\t/** Approves or rejects a request of an employee and audits the decision */\n\tsetApproval(input: {\n\t\tid: number;\n\t\tapproval: AbsenceApproval;\n\t\tnote?: string | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): AbsenceRecord;\n\t/** Deletes an absence by id */\n\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n\t/** Absences of one user within an optional period, sorted by start date */\n\tlistByUser(userId: number, options?: { from?: string; to?: string; year?: number }): AbsenceRecord[];\n\t/** Absences overlapping a date, sorted by start date */\n\tforDate(userId: number, date: string): AbsenceRecord[];\n\t/** Absences overlapping a range, including their type information */\n\twithTypesInRange(userId: number, from: string, to: string): AbsenceWithType[];\n\t/** Absences of **all** employees overlapping a range \u2014 the overview of the administration */\n\tallInRange(from: string, to: string): AbsenceWithType[];\n}\n\ninterface AbsenceTypeRow {\n\tid: number;\n\tuser_id: number | null;\n\tcode: string;\n\tname: string;\n\tpaid: number;\n\tfactor: number;\n\treduce_vacation: number;\n\tis_active: number;\n}\n\ninterface AbsenceRow {\n\tid: number;\n\tuser_id: number;\n\ttype_id: number;\n\tdate_from: string;\n\tdate_to: string;\n\tday_portion: number;\n\thours: number | null;\n\tstatus: AbsenceStatus;\n\tapproval: AbsenceApproval;\n\tdecided_at: number | null;\n\tdecided_by: number | null;\n\tdecision_note: string | null;\n\tnote: string | null;\n\tcreated_at: number;\n\tcreated_by: number | null;\n}\n\ninterface AbsenceWithTypeRow extends AbsenceRow {\n\ttype_code: string;\n\ttype_name: string;\n\ttype_paid: number;\n\ttype_factor: number;\n\ttype_reduce_vacation: number;\n}\n\nconst TYPE_COLUMNS = \"id, user_id, code, name, paid, factor, reduce_vacation, is_active\";\nconst ABSENCE_COLUMNS = `id, user_id, type_id, date_from, date_to, day_portion, hours, status, approval, decided_at, decided_by, decision_note, note,\n\\tcreated_at, created_by`;\n\n/** Field names of an absence that are compared for the audit trail. */\nconst AUDITED_FIELDS: (keyof AbsenceRecord)[] = [\"typeId\", \"dateFrom\", \"dateTo\", \"dayPortion\", \"hours\", \"note\"];\n\n/**\n * Maps a database row to an absence type record.\n *\n * @param row - raw database row\n * @returns absence type record\n */\nexport function mapAbsenceTypeRow(row: AbsenceTypeRow): AbsenceTypeRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuserId: row.user_id,\n\t\tcode: row.code,\n\t\tname: row.name,\n\t\tpaid: row.paid !== 0,\n\t\tfactor: row.factor,\n\t\treduceVacation: row.reduce_vacation !== 0,\n\t\tisActive: row.is_active !== 0,\n\t};\n}\n\n/**\n * Maps a database row to an absence record.\n *\n * @param row - raw database row\n * @returns absence record\n */\nexport function mapAbsenceRow(row: AbsenceRow): AbsenceRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuserId: row.user_id,\n\t\ttypeId: row.type_id,\n\t\tdateFrom: row.date_from,\n\t\tdateTo: row.date_to,\n\t\tdayPortion: row.day_portion,\n\t\thours: row.hours,\n\t\tstatus: row.status,\n\t\tapproval: row.approval,\n\t\tdecidedAt: row.decided_at,\n\t\tdecidedBy: row.decided_by,\n\t\tdecisionNote: row.decision_note,\n\t\tnote: row.note,\n\t\tcreatedAt: row.created_at,\n\t\tcreatedBy: row.created_by,\n\t};\n}\n\n/**\n * Checks and normalises a calendar date.\n *\n * @param value - date to validate\n * @param label - field name used in the error message\n * @returns the validated date\n */\nfunction requireDate(value: string, label: string): string {\n\tconst trimmed = value.trim();\n\tif (!isDateString(trimmed)) {\n\t\tthrow new ValidationError(`${label} \"${value}\" is not a date (YYYY-MM-DD)`);\n\t}\n\treturn trimmed;\n}\n\n/**\n * Validates the day portion of an absence.\n *\n * @param value - raw day portion\n * @returns the validated day portion\n */\nfunction requireDayPortion(value: number): number {\n\tif (!Number.isFinite(value) || value <= 0 || value > 1) {\n\t\tthrow new ValidationError(`dayPortion must be greater than 0 and at most 1 (got ${value})`);\n\t}\n\treturn value;\n}\n\n/**\n * Creates the absence repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createAbsencesRepository(db: Db): AbsencesRepository {\n\tconst selectTypeById = db.prepare(`SELECT ${TYPE_COLUMNS} FROM absence_types WHERE id = ?`);\n\tconst selectTypeUserCode = db.prepare(`SELECT ${TYPE_COLUMNS} FROM absence_types WHERE code = ? AND user_id = ?`);\n\tconst selectTypeGlobalCode = db.prepare(\n\t\t`SELECT ${TYPE_COLUMNS} FROM absence_types WHERE code = ? AND user_id IS NULL`,\n\t);\n\tconst selectAbsenceById = db.prepare(`SELECT ${ABSENCE_COLUMNS} FROM absences WHERE id = ?`);\n\tconst selectByUser = db.prepare(\n\t\t`SELECT ${ABSENCE_COLUMNS} FROM absences\n\t\t WHERE user_id = ? AND date_to >= ? AND date_from <= ?\n\t\t ORDER BY date_from, date_to, id`,\n\t);\n\tconst selectWithTypes = db.prepare(\n\t\t`SELECT a.id, a.user_id, a.type_id, a.date_from, a.date_to, a.day_portion, a.hours, a.status, a.approval,\n\t\t a.decided_at, a.decided_by, a.decision_note, a.note, a.created_at, a.created_by,\n\t\t t.code AS type_code, t.name AS type_name, t.paid AS type_paid, t.factor AS type_factor,\n\t\t t.reduce_vacation AS type_reduce_vacation\n\t\t FROM absences a JOIN absence_types t ON t.id = a.type_id\n\t\t WHERE a.user_id = ? AND a.date_to >= ? AND a.date_from <= ?\n\t\t ORDER BY a.date_from, a.date_to, a.id`,\n\t);\n\t// the same list for the administration: every employee in one answer, so the overview needs one request\n\tconst selectAllWithTypes = db.prepare(\n\t\t`SELECT a.id, a.user_id, a.type_id, a.date_from, a.date_to, a.day_portion, a.hours, a.status, a.approval,\n\t\t a.decided_at, a.decided_by, a.decision_note, a.note, a.created_at, a.created_by,\n\t\t t.code AS type_code, t.name AS type_name, t.paid AS type_paid, t.factor AS type_factor,\n\t\t t.reduce_vacation AS type_reduce_vacation\n\t\t FROM absences a JOIN absence_types t ON t.id = a.type_id\n\t\t WHERE a.date_to >= ? AND a.date_from <= ?\n\t\t ORDER BY a.date_from, a.date_to, a.user_id, a.id`,\n\t);\n\tconst insertAbsence = db.prepare(\n\t\t`INSERT INTO absences\n\t\t (user_id, type_id, date_from, date_to, day_portion, hours, status, approval, decided_at, decided_by,\n\t\t decision_note, note, created_at, created_by)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateAbsence = db.prepare(\n\t\t`UPDATE absences SET type_id = ?, date_from = ?, date_to = ?, day_portion = ?, hours = ?, note = ?\n\t\t WHERE id = ?`,\n\t);\n\tconst updateStatusStatement = db.prepare(\"UPDATE absences SET status = ? WHERE id = ?\");\n\tconst updateApprovalStatement = db.prepare(\n\t\t\"UPDATE absences SET approval = ?, decided_at = ?, decided_by = ?, decision_note = ? WHERE id = ?\",\n\t);\n\tconst deleteAbsence = db.prepare(\"DELETE FROM absences WHERE id = ?\");\n\tconst insertType = db.prepare(\n\t\t`INSERT INTO absence_types (user_id, code, name, paid, factor, reduce_vacation, is_active)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?)`,\n\t);\n\tconst updateType = db.prepare(\n\t\t`UPDATE absence_types SET name = ?, paid = ?, factor = ?, reduce_vacation = ?, is_active = ? WHERE id = ?`,\n\t);\n\n\tconst read = (id: number): AbsenceRecord | null => {\n\t\tconst row = selectAbsenceById.get(id) as AbsenceRow | undefined;\n\t\treturn row ? mapAbsenceRow(row) : null;\n\t};\n\n\t/**\n\t * Resolves a type by id or code (user specific types win over the global ones).\n\t *\n\t * @param reference - id or code\n\t * @param userId - owner used to prefer user specific types\n\t * @returns type record or `null`\n\t */\n\tconst resolveType = (reference: number | string, userId?: number | null): AbsenceTypeRecord | null => {\n\t\tif (typeof reference === \"number\") {\n\t\t\tconst row = selectTypeById.get(reference) as AbsenceTypeRow | undefined;\n\t\t\treturn row ? mapAbsenceTypeRow(row) : null;\n\t\t}\n\t\tif (userId != null) {\n\t\t\tconst own = selectTypeUserCode.get(reference, userId) as AbsenceTypeRow | undefined;\n\t\t\tif (own) {\n\t\t\t\treturn mapAbsenceTypeRow(own);\n\t\t\t}\n\t\t}\n\t\tconst global = selectTypeGlobalCode.get(reference) as AbsenceTypeRow | undefined;\n\t\treturn global ? mapAbsenceTypeRow(global) : null;\n\t};\n\n\t/**\n\t * Resolves a type or throws when it cannot be found.\n\t *\n\t * @param input - type reference and owner\n\t * @param input.typeId - type id (alternative to `input.typeCode`)\n\t * @param input.typeCode - type code (alternative to `input.typeId`)\n\t * @param input.userId - owner used to prefer user specific types\n\t * @returns resolved type\n\t */\n\tconst requireType = (input: { typeId?: number; typeCode?: string; userId: number }): AbsenceTypeRecord => {\n\t\tconst reference = input.typeId ?? input.typeCode;\n\t\tif (reference === undefined) {\n\t\t\tthrow new UnknownAbsenceTypeError(\"(none)\");\n\t\t}\n\t\tconst type = resolveType(reference, input.userId);\n\t\tif (!type) {\n\t\t\tthrow new UnknownAbsenceTypeError(reference);\n\t\t}\n\t\treturn type;\n\t};\n\n\treturn {\n\t\ttypes(options?: { userId?: number | null; includeInactive?: boolean }): AbsenceTypeRecord[] {\n\t\t\tconst activeOnly = options?.includeInactive === true ? \"\" : \" AND is_active = 1\";\n\t\t\tconst userId = options?.userId ?? null;\n\t\t\tconst rows = db\n\t\t\t\t.prepare(\n\t\t\t\t\t`SELECT ${TYPE_COLUMNS} FROM absence_types\n\t\t\t\t\t WHERE (user_id IS NULL OR user_id = ?)${activeOnly}\n\t\t\t\t\t ORDER BY code, id`,\n\t\t\t\t)\n\t\t\t\t.all(userId) as AbsenceTypeRow[];\n\t\t\treturn rows.map(mapAbsenceTypeRow);\n\t\t},\n\n\t\tfindType(reference: number | string, userId?: number | null): AbsenceTypeRecord | null {\n\t\t\treturn resolveType(reference, userId);\n\t\t},\n\n\t\tupsertType(input: UpsertAbsenceTypeInput): { type: AbsenceTypeRecord; created: boolean } {\n\t\t\tconst userId = input.userId ?? null;\n\t\t\tconst code = input.code.trim().toUpperCase();\n\t\t\tif (!/^[A-Za-z0-9_-]{1,8}$/.test(code)) {\n\t\t\t\tthrow new ValidationError(`code must be 1 to 8 letters, digits, \"-\" or \"_\" (got \"${input.code}\")`);\n\t\t\t}\n\t\t\tconst name = input.name.trim();\n\t\t\tif (!name) {\n\t\t\t\tthrow new ValidationError(\"name is required\");\n\t\t\t}\n\t\t\tconst factor = input.factor ?? 100;\n\t\t\tif (!Number.isInteger(factor) || factor < 0 || factor > 100) {\n\t\t\t\tthrow new ValidationError(`factor must be a whole number between 0 and 100 (got ${input.factor})`);\n\t\t\t}\n\t\t\tconst paid = input.paid ?? true;\n\t\t\tconst reduceVacation = input.reduceVacation ?? false;\n\t\t\tconst isActive = input.isActive ?? true;\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tconst existing = resolveType(code, userId);\n\t\t\tlet typeId = existing?.id ?? 0;\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tif (existing) {\n\t\t\t\t\tupdateType.run(name, paid ? 1 : 0, factor, reduceVacation ? 1 : 0, isActive ? 1 : 0, existing.id);\n\t\t\t\t} else {\n\t\t\t\t\tconst result = insertType.run(\n\t\t\t\t\t\tuserId,\n\t\t\t\t\t\tcode,\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tpaid ? 1 : 0,\n\t\t\t\t\t\tfactor,\n\t\t\t\t\t\treduceVacation ? 1 : 0,\n\t\t\t\t\t\tisActive ? 1 : 0,\n\t\t\t\t\t);\n\t\t\t\t\ttypeId = Number(result.lastInsertRowid);\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\tip: input.actorIp ?? null,\n\t\t\t\t\taction: existing ? \"absence_type.update\" : \"absence_type.create\",\n\t\t\t\t\tentity: \"absence_types\",\n\t\t\t\t\tentityId: typeId,\n\t\t\t\t\tdetail: { code, userId, name, paid, factor, reduceVacation, isActive },\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst type = existing\n\t\t\t\t? { ...existing, name, paid, factor, reduceVacation, isActive }\n\t\t\t\t: {\n\t\t\t\t\t\tid: typeId,\n\t\t\t\t\t\tuserId,\n\t\t\t\t\t\tcode,\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tpaid,\n\t\t\t\t\t\tfactor,\n\t\t\t\t\t\treduceVacation,\n\t\t\t\t\t\tisActive,\n\t\t\t\t\t};\n\t\t\treturn { type, created: existing === null };\n\t\t},\n\n\t\tfindById: read,\n\n\t\tcreate(input: CreateAbsenceInput): AbsenceRecord {\n\t\t\tconst type = requireType(input);\n\t\t\tconst dateFrom = requireDate(input.dateFrom, \"dateFrom\");\n\t\t\tconst dateTo = requireDate(input.dateTo ?? dateFrom, \"dateTo\");\n\t\t\tif (dateFrom > dateTo) {\n\t\t\t\tthrow new ValidationError(`dateFrom (${dateFrom}) must not be after dateTo (${dateTo})`);\n\t\t\t}\n\t\t\tconst dayPortion = requireDayPortion(input.dayPortion ?? 1);\n\t\t\tconst status: AbsenceStatus = input.status ?? \"planned\";\n\t\t\t// the administration enters dates straight away, a request of an employee waits for its decision\n\t\t\tconst approval: AbsenceApproval = input.approval ?? \"approved\";\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst decidedAt = approval === \"requested\" ? null : now;\n\t\t\tconst decidedBy = approval === \"requested\" ? null : input.actorId;\n\n\t\t\tlet absenceId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertAbsence.run(\n\t\t\t\t\tinput.userId,\n\t\t\t\t\ttype.id,\n\t\t\t\t\tdateFrom,\n\t\t\t\t\tdateTo,\n\t\t\t\t\tdayPortion,\n\t\t\t\t\tinput.hours ?? null,\n\t\t\t\t\tstatus,\n\t\t\t\t\tapproval,\n\t\t\t\t\tdecidedAt,\n\t\t\t\t\tdecidedBy,\n\t\t\t\t\tnull,\n\t\t\t\t\tinput.note ?? null,\n\t\t\t\t\tnow,\n\t\t\t\t\tinput.actorId,\n\t\t\t\t);\n\t\t\t\tabsenceId = Number(result.lastInsertRowid);\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: \"absence.create\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: absenceId,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: input.userId,\n\t\t\t\t\t\ttypeCode: type.code,\n\t\t\t\t\t\tdateFrom,\n\t\t\t\t\t\tdateTo,\n\t\t\t\t\t\tdayPortion,\n\t\t\t\t\t\tstatus,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst created = read(absenceId);\n\t\t\tif (!created) {\n\t\t\t\tthrow new Error(`absence ${absenceId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn created;\n\t\t},\n\n\t\tupdate(input: UpdateAbsenceInput): AbsenceRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`absence ${input.id} not found`);\n\t\t\t}\n\n\t\t\tconst type =\n\t\t\t\tinput.patch.typeId === undefined && input.patch.typeCode === undefined\n\t\t\t\t\t? null\n\t\t\t\t\t: requireType({ ...input.patch, userId: current.userId });\n\t\t\tconst next: AbsenceRecord = {\n\t\t\t\t...current,\n\t\t\t\ttypeId: type?.id ?? current.typeId,\n\t\t\t\tdateFrom: input.patch.dateFrom ? requireDate(input.patch.dateFrom, \"dateFrom\") : current.dateFrom,\n\t\t\t\tdateTo: input.patch.dateTo ? requireDate(input.patch.dateTo, \"dateTo\") : current.dateTo,\n\t\t\t\tdayPortion:\n\t\t\t\t\tinput.patch.dayPortion === undefined\n\t\t\t\t\t\t? current.dayPortion\n\t\t\t\t\t\t: requireDayPortion(input.patch.dayPortion),\n\t\t\t\thours: input.patch.hours === undefined ? current.hours : input.patch.hours,\n\t\t\t\tnote: input.patch.note === undefined ? current.note : input.patch.note,\n\t\t\t};\n\t\t\tif (next.dateFrom > next.dateTo) {\n\t\t\t\tthrow new ValidationError(`dateFrom (${next.dateFrom}) must not be after dateTo (${next.dateTo})`);\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_FIELDS as unknown as (keyof Record<string, unknown>)[],\n\t\t\t);\n\t\t\tif (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\tupdateAbsence.run(\n\t\t\t\t\tnext.typeId,\n\t\t\t\t\tnext.dateFrom,\n\t\t\t\t\tnext.dateTo,\n\t\t\t\t\tnext.dayPortion,\n\t\t\t\t\tnext.hours,\n\t\t\t\t\tnext.note,\n\t\t\t\t\tinput.id,\n\t\t\t\t);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"absence.update\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { changes, 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(`absence ${input.id} disappeared right after the update`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\tsetStatus(input: {\n\t\t\tid: number;\n\t\t\tstatus: AbsenceStatus;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): AbsenceRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`absence ${input.id} not found`);\n\t\t\t}\n\t\t\tif (current.status === input.status) {\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\tupdateStatusStatement.run(input.status, input.id);\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: \"absence.status\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { changes: { status: { old: current.status, new: input.status } } },\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(`absence ${input.id} disappeared right after the status change`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\tsetApproval(input: {\n\t\t\tid: number;\n\t\t\tapproval: AbsenceApproval;\n\t\t\tnote?: string | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): AbsenceRecord {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\tthrow new NotFoundError(`absence ${input.id} not found`);\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tupdateApprovalStatement.run(input.approval, now, input.actorId, input.note ?? null, input.id);\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: \"absence.approval\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tchanges: { approval: { old: current.approval, new: input.approval } },\n\t\t\t\t\t\t...(input.note ? { note: input.note } : {}),\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst updated = read(input.id);\n\t\t\tif (!updated) {\n\t\t\t\tthrow new Error(`absence ${input.id} disappeared right after the decision`);\n\t\t\t}\n\t\t\treturn updated;\n\t\t},\n\n\t\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst current = read(input.id);\n\t\t\tif (!current) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeleteAbsence.run(input.id);\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: \"absence.delete\",\n\t\t\t\t\tentity: \"absence\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuserId: current.userId,\n\t\t\t\t\t\tdateFrom: current.dateFrom,\n\t\t\t\t\t\tdateTo: current.dateTo,\n\t\t\t\t\t\tstatus: current.status,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\n\t\tlistByUser(userId: number, options?: { from?: string; to?: string; year?: number }): AbsenceRecord[] {\n\t\t\tconst from = options?.from ?? (options?.year === undefined ? \"0000-01-01\" : `${options.year}-01-01`);\n\t\t\tconst to = options?.to ?? (options?.year === undefined ? \"9999-12-31\" : `${options.year}-12-31`);\n\t\t\tconst rows = selectByUser.all(userId, from, to) as AbsenceRow[];\n\t\t\treturn rows.map(mapAbsenceRow);\n\t\t},\n\n\t\tforDate(userId: number, date: string): AbsenceRecord[] {\n\t\t\tconst day = requireDate(date, \"date\");\n\t\t\tconst rows = selectByUser.all(userId, day, day) as AbsenceRow[];\n\t\t\treturn rows.map(mapAbsenceRow);\n\t\t},\n\n\t\twithTypesInRange(userId: number, from: string, to: string): AbsenceWithType[] {\n\t\t\tconst rows = selectWithTypes.all(\n\t\t\t\tuserId,\n\t\t\t\trequireDate(from, \"from\"),\n\t\t\t\trequireDate(to, \"to\"),\n\t\t\t) as AbsenceWithTypeRow[];\n\t\t\treturn rows.map(row => ({\n\t\t\t\t...mapAbsenceRow(row),\n\t\t\t\ttypeCode: row.type_code,\n\t\t\t\ttypeName: row.type_name,\n\t\t\t\tpaid: row.type_paid !== 0,\n\t\t\t\tfactor: row.type_factor,\n\t\t\t\treduceVacation: row.type_reduce_vacation !== 0,\n\t\t\t}));\n\t\t},\n\n\t\tallInRange(from: string, to: string): AbsenceWithType[] {\n\t\t\tconst rows = selectAllWithTypes.all(\n\t\t\t\trequireDate(from, \"from\"),\n\t\t\t\trequireDate(to, \"to\"),\n\t\t\t) as AbsenceWithTypeRow[];\n\t\t\treturn rows.map(row => ({\n\t\t\t\t...mapAbsenceRow(row),\n\t\t\t\ttypeCode: row.type_code,\n\t\t\t\ttypeName: row.type_name,\n\t\t\t\tpaid: row.type_paid !== 0,\n\t\t\t\tfactor: row.type_factor,\n\t\t\t\treduceVacation: row.type_reduce_vacation !== 0,\n\t\t\t}));\n\t\t},\n\t};\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["_a", "_b", "_c"]
7
7
  }
@@ -24,6 +24,7 @@ __export(aggregation_exports, {
24
24
  mapYearRow: () => mapYearRow
25
25
  });
26
26
  module.exports = __toCommonJS(aggregation_exports);
27
+ var import_absences = require("../db/repositories/absences");
27
28
  var import_errors = require("../errors");
28
29
  var import_calculation = require("../domain/calculation");
29
30
  var import_entries = require("../db/repositories/entries");
@@ -171,7 +172,7 @@ function createAggregationService(deps) {
171
172
  return (_a = users.getWorkProfile(userId)) != null ? _a : defaultProfile(userId);
172
173
  };
173
174
  const absenceFor = (userId, date) => {
174
- const covering = absences.withTypesInRange(userId, date, date);
175
+ const covering = absences.withTypesInRange(userId, date, date).filter(import_absences.isApproved);
175
176
  let best = null;
176
177
  for (const absence of covering) {
177
178
  const share = Math.max(0, Math.min(100, absence.factor)) / 100 * absence.dayPortion;
@@ -226,7 +227,7 @@ function createAggregationService(deps) {
226
227
  const vacationDaysInRange = (args) => {
227
228
  const year = Number(args.to.slice(0, 4));
228
229
  const holidayDates = holidays.dateSet(year, holidayRegion());
229
- const overlapping = absences.withTypesInRange(args.userId, args.from, args.to);
230
+ const overlapping = absences.withTypesInRange(args.userId, args.from, args.to).filter(import_absences.isApproved);
230
231
  let days = 0;
231
232
  for (const absence of overlapping) {
232
233
  if (!absence.reduceVacation || absence.status !== args.status) {
@@ -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\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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",
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 { isApproved } 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\t// a request waits for its decision and a rejected one never counts: only effective days are credited\n\t\tconst covering = absences.withTypesInRange(userId, date, date).filter(isApproved);\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\t// only effective days count \u2014 a request still waits for its decision, a rejected one never counts\n\t\tconst overlapping = absences.withTypesInRange(args.userId, args.from, args.to).filter(isApproved);\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
  }
@@ -34,6 +34,7 @@ __export(api_exports, {
34
34
  module.exports = __toCommonJS(api_exports);
35
35
  var import_node_crypto = require("node:crypto");
36
36
  var fs = __toESM(require("node:fs"));
37
+ var import_absences = require("../db/repositories/absences");
37
38
  var import_audit = require("../db/repositories/audit");
38
39
  var import_rfid = require("../db/repositories/rfid");
39
40
  var import_auth = require("../services/auth");
@@ -792,6 +793,18 @@ function createApi(deps) {
792
793
  return { ...absence, typeCode: (_a2 = type == null ? void 0 : type.code) != null ? _a2 : null, typeName: (_b2 = type == null ? void 0 : type.name) != null ? _b2 : null };
793
794
  };
794
795
  route("GET", "/absences", { permission: "report.view_own" }, (context) => {
796
+ var _a2;
797
+ if (context.query("scope") === "all") {
798
+ if (!((_a2 = context.auth) == null ? void 0 : _a2.permissions.includes("absence.approve"))) {
799
+ throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: absence.approve)");
800
+ }
801
+ const from = context.query("from");
802
+ const to = context.query("to");
803
+ if (!from || !to) {
804
+ throw new import_errors.ValidationError("from and to are required with scope=all");
805
+ }
806
+ return (0, import_router.json)(200, { absences: absences.allInRange(from, to).map(publicAbsence) });
807
+ }
795
808
  const requested = context.query("userId") ? Number(context.query("userId")) : null;
796
809
  const userId = resolveScope(context, requested, "report.view_own", "report.view_other");
797
810
  const year = context.query("year") ? Number(context.query("year")) : void 0;
@@ -810,6 +823,8 @@ function createApi(deps) {
810
823
  if (status !== null && status !== "taken" && status !== "planned") {
811
824
  throw new import_errors.ValidationError(`status must be taken or planned (got ${status})`);
812
825
  }
826
+ const mayDecide = context.auth.permissions.includes("absence.edit_other");
827
+ const approval = mayDecide ? "approved" : "requested";
813
828
  const created = absences.create({
814
829
  userId,
815
830
  typeCode: (_a2 = optionalString(body, "typeCode")) != null ? _a2 : void 0,
@@ -819,6 +834,7 @@ function createApi(deps) {
819
834
  dayPortion: (_d2 = optionalNumber(body, "dayPortion")) != null ? _d2 : void 0,
820
835
  hours: optionalNumber(body, "hours"),
821
836
  status: status != null ? status : void 0,
837
+ approval,
822
838
  note: optionalString(body, "note"),
823
839
  actorId: context.auth.user.id,
824
840
  actorIp: (_e = context.request.remoteAddress) != null ? _e : null,
@@ -846,6 +862,27 @@ function createApi(deps) {
846
862
  emit({ type: "absence.change", userId: updated.userId, data: { absenceId: updated.id, action: "status" } });
847
863
  return (0, import_router.json)(200, { absence: publicAbsence(updated) });
848
864
  });
865
+ route("POST", "/absences/:id/approval", { permission: "absence.approve", csrf: true }, (context) => {
866
+ var _a2;
867
+ if (!context.auth) {
868
+ throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
869
+ }
870
+ const body = context.jsonBody();
871
+ const approval = requireString(body, "approval");
872
+ if (approval !== "requested" && approval !== "approved" && approval !== "rejected") {
873
+ throw new import_errors.ValidationError(`approval must be requested, approved or rejected (got ${approval})`);
874
+ }
875
+ const updated = absences.setApproval({
876
+ id: numberParam(context, "id"),
877
+ approval,
878
+ note: optionalString(body, "note"),
879
+ actorId: context.auth.user.id,
880
+ actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
881
+ now: now()
882
+ });
883
+ emit({ type: "absence.change", userId: updated.userId, data: { absenceId: updated.id, action: "approval" } });
884
+ return (0, import_router.json)(200, { absence: publicAbsence(updated) });
885
+ });
849
886
  const changeableAbsence = (context) => {
850
887
  if (!context.auth) {
851
888
  throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
@@ -860,7 +897,7 @@ function createApi(deps) {
860
897
  return absence;
861
898
  };
862
899
  route("PATCH", "/absences/:id", { permission: "absence.request", csrf: true }, (context) => {
863
- var _a2, _b2, _c2, _d2, _e, _f, _g, _h, _i, _j, _k, _l, _m;
900
+ var _a2, _b2, _c2, _d2, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q;
864
901
  const absence = changeableAbsence(context);
865
902
  const body = context.jsonBody();
866
903
  const status = optionalString(body, "status");
@@ -889,7 +926,7 @@ function createApi(deps) {
889
926
  now: now()
890
927
  });
891
928
  }
892
- const updated = hasFields ? absences.update({
929
+ const changed = hasFields ? absences.update({
893
930
  id: absence.id,
894
931
  patch,
895
932
  reason: optionalString(body, "reason"),
@@ -897,6 +934,14 @@ function createApi(deps) {
897
934
  actorIp: (_l = context.request.remoteAddress) != null ? _l : null,
898
935
  now: now()
899
936
  }) : (_m = absences.findById(absence.id)) != null ? _m : absence;
937
+ const updated = hasFields && !((_n = context.auth) == null ? void 0 : _n.permissions.includes("absence.edit_other")) && changed.approval !== "requested" ? absences.setApproval({
938
+ id: changed.id,
939
+ approval: "requested",
940
+ note: null,
941
+ actorId: (_p = (_o = context.auth) == null ? void 0 : _o.user.id) != null ? _p : 0,
942
+ actorIp: (_q = context.request.remoteAddress) != null ? _q : null,
943
+ now: now()
944
+ }) : changed;
900
945
  emit({ type: "absence.change", userId: updated.userId, data: { absenceId: updated.id, action: "updated" } });
901
946
  return (0, import_router.json)(200, { absence: publicAbsence(updated) });
902
947
  });
@@ -2407,7 +2452,8 @@ function createApi(deps) {
2407
2452
  year,
2408
2453
  month,
2409
2454
  days: aggregation.days(userId, first, last),
2410
- absences: absences.withTypesInRange(userId, first, last).map((absence) => ({
2455
+ // the document only lists effective absences — a request waits for its decision
2456
+ absences: absences.withTypesInRange(userId, first, last).filter(import_absences.isApproved).map((absence) => ({
2411
2457
  typeCode: absence.typeCode,
2412
2458
  typeName: absence.typeName,
2413
2459
  dateFrom: absence.dateFrom,