dutybeat.psm1

# DutyBeat public API — PowerShell client.
# Works on Windows PowerShell 5.1 and PowerShell 7+ (Core). No external dependencies.

$script:DefaultBaseUri = 'https://api.dutybeat.com'
# Session state set by Connect-DutyBeat (per module load).
$script:DutyBeatContext = @{ ApiKey = $null; BaseUri = $script:DefaultBaseUri }

function Connect-DutyBeat {
    <#
    .SYNOPSIS
        Stores the API key (and optional base URL) for subsequent calls in this session.
    .DESCRIPTION
        Saves your DutyBeat API key for the current PowerShell session so you don't have to pass it to
        every command. Alternatively, set the DUTYBEAT_API_KEY environment variable and skip this call.
    .PARAMETER ApiKey
        Your API key (starts with db_live_). Create one in the app under Settings → API keys.
        If omitted, the DUTYBEAT_API_KEY environment variable is used.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the production API; override only for testing.
    .EXAMPLE
        Connect-DutyBeat -ApiKey 'db_live_...'
    .LINK
        https://dutybeat.com/manuales/api
    #>

    [CmdletBinding()]
    param(
        [string]$ApiKey,
        [string]$BaseUri = $script:DefaultBaseUri
    )
    $key = if ($ApiKey) { $ApiKey } elseif ($env:DUTYBEAT_API_KEY) { $env:DUTYBEAT_API_KEY } else { $null }
    if (-not $key) {
        throw "Provide -ApiKey or set the DUTYBEAT_API_KEY environment variable."
    }
    $script:DutyBeatContext = @{ ApiKey = $key; BaseUri = $BaseUri.TrimEnd('/') }
    Write-Verbose "Connected to $($script:DutyBeatContext.BaseUri)"
}

function Invoke-DbApi {
    # Private transport: resolves the key/base URL, builds the URI, retries on 429/5xx honouring
    # Retry-After, and maps the API error envelope to a terminating error. Not exported.
    [CmdletBinding()]
    param(
        [string]$Method = 'GET',
        [Parameter(Mandatory)][string]$Path,
        [hashtable]$Query,
        [hashtable]$Body,
        [string]$ApiKey,
        [string]$BaseUri,
        [int]$MaxRetries = 2
    )
    $key = if ($ApiKey) { $ApiKey }
        elseif ($script:DutyBeatContext.ApiKey) { $script:DutyBeatContext.ApiKey }
        elseif ($env:DUTYBEAT_API_KEY) { $env:DUTYBEAT_API_KEY }
        else { $null }
    if (-not $key) {
        throw "No API key. Run Connect-DutyBeat -ApiKey '...' or set DUTYBEAT_API_KEY."
    }
    $base = if ($BaseUri) { $BaseUri.TrimEnd('/') }
        elseif ($script:DutyBeatContext.BaseUri) { $script:DutyBeatContext.BaseUri }
        else { $script:DefaultBaseUri }

    $uri = "$base$Path"
    if ($Query -and $Query.Count -gt 0) {
        $pairs = foreach ($k in $Query.Keys) { "$k=$([uri]::EscapeDataString([string]$Query[$k]))" }
        $uri += '?' + ($pairs -join '&')
    }
    $headers = @{ Authorization = "Bearer $key"; Accept = 'application/json' }

    # A JSON body for write methods (POST/PATCH). Depth 5 covers the flat payloads we send.
    $restArgs = @{ Method = $Method; Uri = $uri; Headers = $headers; ErrorAction = 'Stop' }
    if ($PSBoundParameters.ContainsKey('Body') -and $Body) {
        $restArgs.Body = ($Body | ConvertTo-Json -Depth 5 -Compress)
        $restArgs.ContentType = 'application/json'
    }

    for ($attempt = 0; ; $attempt++) {
        try {
            return Invoke-RestMethod @restArgs
        }
        catch {
            $status = $null
            try { $status = [int]$_.Exception.Response.StatusCode } catch {}

            if (($status -in 429, 500, 502, 503, 504) -and $attempt -lt $MaxRetries) {
                $delay = [Math]::Min([Math]::Pow(2, $attempt), 8)
                try {
                    $ra = $_.Exception.Response.Headers['Retry-After']
                    if ($ra) { $delay = [double]$ra }
                } catch {}
                Start-Sleep -Seconds $delay
                continue
            }

            $code = $null
            $message = $_.Exception.Message
            if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
                try {
                    $body = $_.ErrorDetails.Message | ConvertFrom-Json
                    if ($body.error) { $code = $body.error.code; $message = $body.error.message }
                } catch {}
            }
            $prefix = 'DutyBeat API error'
            if ($status) { $prefix += " $status" }
            if ($code) { $prefix += " ($code)" }
            throw "${prefix}: $message"
        }
    }
}

function Get-DbMe {
    <#
    .SYNOPSIS
        Returns who the API key is (GET /api/v1/me).
    .DESCRIPTION
        Returns the company (tenant) the key belongs to, the methods it can call (scopes) and the user
        it acts as (acts_as_user, whose permissions authorize the key's writes). Not scope-gated: any
        valid key can call it, so it's a good first request to verify the key works. The `data` object
        has `tenant` ({ id, name }), `scopes` (an array of method ids) and `acts_as_user`
        ({ id, email, name }, or $null for a legacy key with no bound user).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `tenant` ({ id, name }), `scopes` (array of method ids) and `acts_as_user`
        ({ id, email, name } or $null).
    .EXAMPLE
        Get-DbMe
    .EXAMPLE
        (Get-DbMe).scopes -join ', '
    .EXAMPLE
        (Get-DbMe).acts_as_user.name
    .LINK
        https://dutybeat.com/manuales/api/identidad-clave
    #>

    [CmdletBinding()]
    param(
        [string]$ApiKey,
        [string]$BaseUri
    )
    $response = Invoke-DbApi -Method 'GET' -Path '/api/v1/me' -ApiKey $ApiKey -BaseUri $BaseUri
    $response.data
}

function Get-DbUser {
    <#
    .SYNOPSIS
        Gets a user's full record (GET /api/v1/users/:user_id).
    .DESCRIPTION
        Returns the account and profile data for one user — the same fields shown on the user's profile
        page in the app (department, work center, DNI, IBAN, SSN, and more). Requires a key with the users.get method
        enabled. A user from another company (or a non-existent id) raises a not-found error.
    .PARAMETER UserId
        The user's id within your company.
    .PARAMETER IncludeFolders
        Also return the employee's document folders (metadata only: id, name and document count).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with the user's fields: id, full_name, email, role, status, department,
        work_center, profile (dni, iban, ssn, ...) and, if requested, folders.
    .EXAMPLE
        Get-DbUser -UserId '9f1c...' -IncludeFolders
    .EXAMPLE
        Connect-DutyBeat -ApiKey 'db_live_...'
        (Get-DbUser -UserId '9f1c...').profile.iban
    .LINK
        https://dutybeat.com/manuales/api/leer-usuario
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)][string]$UserId,
        [switch]$IncludeFolders,
        [string]$ApiKey,
        [string]$BaseUri
    )
    process {
        $query = @{}
        if ($IncludeFolders) { $query['include_folders'] = 'true' }
        $response = Invoke-DbApi -Method 'GET' -Path "/api/v1/users/$UserId" -Query $query -ApiKey $ApiKey -BaseUri $BaseUri
        $response.data
    }
}

function Get-DbUsers {
    <#
    .SYNOPSIS
        Lists users, paginated (GET /api/v1/users).
    .DESCRIPTION
        Returns a page of users from your company (25 per page by default, max 100), with optional
        filters. The result has a `data` array (the users on this page) and a `meta` object with
        `page`, `page_size` and `total`. Iterate -Page 1, 2, ... until you have seen `meta.total` users.
        Requires a key with the users.list method enabled.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Users per page (default 25, max 100).
    .PARAMETER Detail
        Level of detail: 'reduced' (default, basic fields) or 'full' (complete record with profile).
    .PARAMETER Status
        Filter by status: 'active' or 'disabled'.
    .PARAMETER Role
        Filter by role: 'member' or 'admin'.
    .PARAMETER DepartmentId
        Filter by department id.
    .PARAMETER WorkCenterId
        Filter by work center (sede) id.
    .PARAMETER Email
        Resolve an exact email (case-insensitive) to its user — at most one result. Use it to map your
        own external identifier (the email) to our id.
    .PARAMETER Query
        Search by full name or email (substring). For an exact email, use -Email.
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `data` (an array of users) and `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbUsers -PageSize 50 -Status active
    .EXAMPLE
        (Get-DbUsers -Email 'javier.ortega@atlantica-ing.com').data[0].id
    .EXAMPLE
        (Get-DbUsers -Detail full -Role admin).data | ForEach-Object { $_.profile.iban }
    .LINK
        https://dutybeat.com/manuales/api/listar-usuarios
    #>

    [CmdletBinding()]
    param(
        [int]$Page = 1,
        [ValidateRange(1, 100)][int]$PageSize = 25,
        [ValidateSet('reduced', 'full')][string]$Detail = 'reduced',
        [ValidateSet('active', 'disabled')][string]$Status,
        [ValidateSet('member', 'admin')][string]$Role,
        [string]$DepartmentId,
        [string]$WorkCenterId,
        [string]$Email,
        [string]$Query,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $q = @{ page = $Page; page_size = $PageSize; detail = $Detail }
    if ($Status)       { $q['status'] = $Status }
    if ($Role)         { $q['role'] = $Role }
    if ($DepartmentId) { $q['department_id'] = $DepartmentId }
    if ($WorkCenterId) { $q['work_center_id'] = $WorkCenterId }
    if ($Email)        { $q['email'] = $Email }
    if ($Query)        { $q['q'] = $Query }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/users' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function New-DbUser {
    <#
    .SYNOPSIS
        Creates an employee (POST /api/v1/users).
    .DESCRIPTION
        Registers an employee (account fields) in your company. Only -Email and -FullName are required.
        -Password is optional: omit it and the employee sets their own via "forgot password"; if given
        it must be at least 8 characters. Creating a -Role 'admin' user requires the key to act as an
        administrator. The email must be unique. Returns the created user's `data` object.
    .PARAMETER Email
        Login email (must be unique).
    .PARAMETER FullName
        The employee's full name.
    .PARAMETER Password
        Optional initial password (min. 8 chars). If omitted, the employee sets it via reset.
    .PARAMETER Role
        'member' (default) or 'admin' (requires an admin key).
    .PARAMETER DepartmentId
        Department id the employee belongs to.
    .PARAMETER WorkCenterId
        Work center (sede) id.
    .PARAMETER HireDate
        Hire date (YYYY-MM-DD).
    .PARAMETER VacationDays
        Annual vacation days (defaults to the company's).
    .PARAMETER WorksHolidays
        Whether they work on public holidays.
    .PARAMETER RemoteWorkAllowed
        Whether they may clock in remotely.
    .PARAMETER Dni
        DNI/NIE — the kiosk identifier is derived from its digits.
    .PARAMETER Phone
        Phone — the initial kiosk PIN is derived from its last 4 digits.
    .OUTPUTS
        PSCustomObject: the created user (same shape as Get-DbUser).
    .EXAMPLE
        New-DbUser -Email ana@empresa.com -FullName 'Ana Ruiz' -DepartmentId dept-estm
    .LINK
        https://dutybeat.com/manuales/api/crear-usuario
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Email,
        [Parameter(Mandatory)][string]$FullName,
        [string]$Password,
        [ValidateSet('member', 'admin')][string]$Role,
        [string]$DepartmentId,
        [string]$WorkCenterId,
        [string]$HireDate,
        [int]$VacationDays,
        [switch]$WorksHolidays,
        [switch]$RemoteWorkAllowed,
        [string]$Dni,
        [string]$Phone,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $body = @{ email = $Email; full_name = $FullName }
    if ($Password)     { $body['password'] = $Password }
    if ($Role)         { $body['role'] = $Role }
    if ($DepartmentId) { $body['department_id'] = $DepartmentId }
    if ($WorkCenterId) { $body['work_center_id'] = $WorkCenterId }
    if ($HireDate)     { $body['hire_date'] = $HireDate }
    if ($PSBoundParameters.ContainsKey('VacationDays')) { $body['vacation_days'] = $VacationDays }
    if ($WorksHolidays.IsPresent)     { $body['works_holidays'] = $true }
    if ($RemoteWorkAllowed.IsPresent) { $body['remote_work_allowed'] = $true }
    if ($Dni)   { $body['dni'] = $Dni }
    if ($Phone) { $body['phone'] = $Phone }
    $response = Invoke-DbApi -Method 'POST' -Path '/api/v1/users' -Body $body -ApiKey $ApiKey -BaseUri $BaseUri
    $response.data
}

function Set-DbUser {
    <#
    .SYNOPSIS
        Updates an employee (PATCH /api/v1/users/:user_id).
    .DESCRIPTION
        Partial edit: only the fields you pass in -Fields are changed; anything omitted is kept, and a
        field set to $null is cleared. Accepts both account fields (full_name, role, department_id,
        work_center_id, hire_date, vacation_days, works_holidays, remote_work_allowed) and any profile
        column (dni, iban, ssn, address, ...). Changing role to 'admin' requires the key to act as an
        administrator. Email and status are not editable here. Returns the updated user's `data` object.
    .PARAMETER UserId
        The user's id within your company.
    .PARAMETER Fields
        A hashtable of the fields to change, e.g. @{ remote_work_allowed = $true; iban = 'ES..' }.
    .OUTPUTS
        PSCustomObject: the updated user (same shape as Get-DbUser).
    .EXAMPLE
        Set-DbUser -UserId 'u-estm1' -Fields @{ remote_work_allowed = $true; iban = 'ES91...' }
    .LINK
        https://dutybeat.com/manuales/api/editar-usuario
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$UserId,
        [Parameter(Mandatory)][hashtable]$Fields,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $response = Invoke-DbApi -Method 'PATCH' -Path "/api/v1/users/$UserId" -Body $Fields -ApiKey $ApiKey -BaseUri $BaseUri
    $response.data
}

function Disable-DbUser {
    <#
    .SYNOPSIS
        Deactivates an employee (POST /api/v1/users/:user_id/deactivate).
    .DESCRIPTION
        Disables an employee (offboarding): they can no longer sign in and their sessions are revoked.
        Idempotent — deactivating an already-disabled user also returns the user. The last active admin
        cannot be deactivated. Returns the user's `data` object, now with status 'disabled'.
    .PARAMETER UserId
        The user's id within your company.
    .OUTPUTS
        PSCustomObject: the user, now with status 'disabled'.
    .EXAMPLE
        Disable-DbUser -UserId 'u-nuevo'
    .LINK
        https://dutybeat.com/manuales/api/dar-de-baja-usuario
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$UserId,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $response = Invoke-DbApi -Method 'POST' -Path "/api/v1/users/$UserId/deactivate" -ApiKey $ApiKey -BaseUri $BaseUri
    $response.data
}

function Get-DbAttendance {
    <#
    .SYNOPSIS
        Lists an employee's workdays (GET /api/v1/attendance).
    .DESCRIPTION
        Returns one entry per calendar day of the range [From, To] (both inclusive) for a single
        employee: the day's marks, the minutes actually worked (pauses already discounted) and the
        balance against their schedule. The result has a `data` array (the days on this page) and a
        `meta` object with `page`, `page_size` and `total`. The range cannot exceed 366 days.
        Requires a key with the attendance.list method enabled.
    .PARAMETER UserId
        The employee's id within your company.
    .PARAMETER From
        First day of the range, as YYYY-MM-DD.
    .PARAMETER To
        Last day of the range, as YYYY-MM-DD. Inclusive.
    .PARAMETER TimeZone
        IANA time zone used to bucket marks into days. Defaults to Europe/Madrid.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Days per page (default 25, max 100).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `data` (an array of days) and `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbAttendance -UserId 'u-delm' -From '2026-06-01' -To '2026-06-30'
    .EXAMPLE
        (Get-DbAttendance -UserId 'u-delm' -From '2026-06-01' -To '2026-06-05').data |
            ForEach-Object { '{0}: {1} min' -f $_.date, $_.worked_minutes }
    .LINK
        https://dutybeat.com/manuales/api/listar-fichajes
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$UserId,
        [Parameter(Mandatory)][string]$From,
        [Parameter(Mandatory)][string]$To,
        [string]$TimeZone,
        [int]$Page = 1,
        [ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $q = @{ user_id = $UserId; from = $From; to = $To; page = $Page; page_size = $PageSize }
    if ($TimeZone) { $q['tz'] = $TimeZone }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/attendance' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function Get-DbAttendanceSummary {
    <#
    .SYNOPSIS
        Consolidated attendance totals per employee over a range (GET /api/v1/attendance/summary).
    .DESCRIPTION
        Returns one row per employee with their consolidated totals over [From, To] (both inclusive):
        minutes worked (pauses already discounted), minutes expected against their schedule and the
        balance between the two, plus days worked and expected. This is the payroll query ("hours per
        employee this month"), the company-wide counterpart to Get-DbAttendance (per employee, per day).
        Filterable by status, department and work center like the employee list. The result has a `data`
        array (this page of employees) and a `meta` object with `page`, `page_size` and `total`. The
        range cannot exceed 366 days. Requires a key with the attendance.summary method enabled.
    .PARAMETER From
        First day of the range, as YYYY-MM-DD.
    .PARAMETER To
        Last day of the range, as YYYY-MM-DD. Inclusive.
    .PARAMETER TimeZone
        IANA time zone used to bucket marks into days. Defaults to Europe/Madrid.
    .PARAMETER Status
        Filter by employee status: 'active' or 'disabled'.
    .PARAMETER DepartmentId
        Only employees of this department.
    .PARAMETER WorkCenterId
        Only employees of this work center (sede).
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Employees per page (default 25, max 100).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `data` (an array of per-employee totals) and `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbAttendanceSummary -From '2026-06-01' -To '2026-06-30'
    .EXAMPLE
        (Get-DbAttendanceSummary -From '2026-06-01' -To '2026-06-30').data |
            ForEach-Object { '{0}: {1} min' -f $_.full_name, $_.worked_minutes }
    .LINK
        https://dutybeat.com/manuales/api/resumen-jornada
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$From,
        [Parameter(Mandatory)][string]$To,
        [string]$TimeZone,
        [ValidateSet('active', 'disabled')][string]$Status,
        [string]$DepartmentId,
        [string]$WorkCenterId,
        [int]$Page = 1,
        [ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $q = @{ from = $From; to = $To; page = $Page; page_size = $PageSize }
    if ($TimeZone)     { $q['tz'] = $TimeZone }
    if ($Status)       { $q['status'] = $Status }
    if ($DepartmentId) { $q['department_id'] = $DepartmentId }
    if ($WorkCenterId) { $q['work_center_id'] = $WorkCenterId }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/attendance/summary' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function Get-DbAbsence {
    <#
    .SYNOPSIS
        Gets one absence by id, or lists absences overlapping a date range (GET /api/v1/absences).
    .DESCRIPTION
        With -Id, returns a single absence by its id (GET /api/v1/absences/:absence_id) — the `data`
        object with its type (key + label), status, dates and, for hourly absences, the time slice; an
        unknown or cross-company id raises a not-found error. Requires a key with the absences.get method.

        Otherwise (with -From/-To) returns every absence that overlaps the range [From, To] (both
        inclusive), newest first — including those that start before the window or end after it. The
        result has a `data` array and a `meta` object with `page`, `page_size` and `total`. The range
        cannot exceed 366 days. Requires a key with the absences.list method enabled.
    .PARAMETER Id
        The absence's id within your company. Selects the single-absence lookup.
    .PARAMETER From
        First day of the range, as YYYY-MM-DD.
    .PARAMETER To
        Last day of the range, as YYYY-MM-DD. Inclusive.
    .PARAMETER UserId
        Only this employee's absences. Omit for the whole company.
    .PARAMETER Status
        Filter by status: 'pending', 'approved', 'rejected' or 'cancelled'.
    .PARAMETER Type
        Filter by absence type key (e.g. 'vacation', 'sick'). The catalogue is per-company.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Absences per page (default 25, max 100).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        With -Id: the absence object. Otherwise a PSCustomObject with `data` (an array of absences) and
        `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbAbsence -Id 'abs-008'
    .EXAMPLE
        Get-DbAbsence -From '2026-06-01' -To '2026-06-30' -Status approved
    .EXAMPLE
        (Get-DbAbsence -From '2026-06-01' -To '2026-06-30' -UserId 'u-delm').data |
            ForEach-Object { '{0} {1}' -f $_.start_date, $_.type.label }
    .LINK
        https://dutybeat.com/manuales/api/leer-ausencia
    #>

    [CmdletBinding(DefaultParameterSetName = 'List')]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'Get')][string]$Id,
        [Parameter(Mandatory, ParameterSetName = 'List')][string]$From,
        [Parameter(Mandatory, ParameterSetName = 'List')][string]$To,
        [Parameter(ParameterSetName = 'List')][string]$UserId,
        [Parameter(ParameterSetName = 'List')][ValidateSet('pending', 'approved', 'rejected', 'cancelled')][string]$Status,
        [Parameter(ParameterSetName = 'List')][string]$Type,
        [Parameter(ParameterSetName = 'List')][int]$Page = 1,
        [Parameter(ParameterSetName = 'List')][ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    if ($PSCmdlet.ParameterSetName -eq 'Get') {
        $response = Invoke-DbApi -Method 'GET' -Path "/api/v1/absences/$Id" -ApiKey $ApiKey -BaseUri $BaseUri
        return $response.data
    }
    $q = @{ from = $From; to = $To; page = $Page; page_size = $PageSize }
    if ($UserId) { $q['user_id'] = $UserId }
    if ($Status) { $q['status'] = $Status }
    if ($Type)   { $q['type'] = $Type }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/absences' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function Get-DbDepartment {
    <#
    .SYNOPSIS
        Lists departments, paginated (GET /api/v1/departments).
    .DESCRIPTION
        Returns a page of your company's departments, alphabetical, each with its employee headcount
        (`employee_count`) and its supervisor department (`supervisor`, the one above it in the org tree,
        or $null at the root). The result has a `data` array (the departments on this page) and a `meta`
        object with `page`, `page_size` and `total`. Iterate -Page 1, 2, ... until you have seen
        `meta.total` departments. Requires a key with the departments.list method enabled.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Departments per page (default 25, max 100).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `data` (an array of departments) and `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbDepartment -PageSize 50
    .EXAMPLE
        (Get-DbDepartment).data | ForEach-Object { '{0}: {1}' -f $_.name, $_.employee_count }
    .LINK
        https://dutybeat.com/manuales/api/listar-departamentos
    #>

    [CmdletBinding()]
    param(
        [int]$Page = 1,
        [ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $q = @{ page = $Page; page_size = $PageSize }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/departments' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function Get-DbWorkCenter {
    <#
    .SYNOPSIS
        Lists work centers / sedes, paginated (GET /api/v1/work-centers).
    .DESCRIPTION
        Returns a page of your company's work centers (sedes), alphabetical, each with its location:
        `country` and — when set — `region`, `province` and `municipality`, each a { code, name } object
        (or $null). The result has a `data` array (the work centers on this page) and a `meta` object with
        `page`, `page_size` and `total`. Iterate -Page 1, 2, ... until you have seen `meta.total`.
        Requires a key with the work-centers.list method enabled.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Work centers per page (default 25, max 100).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `data` (an array of work centers) and `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbWorkCenter -PageSize 50
    .EXAMPLE
        (Get-DbWorkCenter).data | ForEach-Object { '{0}: {1}' -f $_.name, $_.region.name }
    .LINK
        https://dutybeat.com/manuales/api/listar-sedes
    #>

    [CmdletBinding()]
    param(
        [int]$Page = 1,
        [ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $q = @{ page = $Page; page_size = $PageSize }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/work-centers' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function Get-DbExpense {
    <#
    .SYNOPSIS
        Gets one expense by id, or lists expenses / receipts (GET /api/v1/expenses).
    .DESCRIPTION
        With -Id, returns a single expense by its id (GET /api/v1/expenses/:expense_id) — the `data`
        object with its OCR money fields, category, status and reconciled flag; an unknown or cross-company
        id raises a not-found error. Requires a key with the expenses.get method.

        Otherwise returns a page of your company's expense receipts (Mis Gastos), newest first. Each
        expense carries its OCR-extracted money fields (`total_amount`, `tax_amount`, `currency`,
        `amount_eur`, `fx_rate`), `category`, `status` (processing / imported / error) and `reconciled`
        (whether a bank transaction is matched to it). The result has a `data` array and a `meta` object
        with `page`, `page_size` and `total`. Requires a key with the expenses.list method enabled.
    .PARAMETER Id
        The expense's id within your company. Selects the single-expense lookup.
    .PARAMETER UserId
        Only this employee's expenses. Omit for the whole company.
    .PARAMETER Status
        Filter by status: 'processing', 'imported' or 'error'.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Expenses per page (default 25, max 100).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        With -Id: the expense object. Otherwise a PSCustomObject with `data` (an array of expenses) and
        `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbExpense -Id 'exp-dem-1'
    .EXAMPLE
        Get-DbExpense -Status imported
    .EXAMPLE
        (Get-DbExpense -UserId 'u-civm1').data | ForEach-Object { '{0}: {1} {2}' -f $_.merchant, $_.amount_eur, $_.currency }
    .LINK
        https://dutybeat.com/manuales/api/leer-gasto
    #>

    [CmdletBinding(DefaultParameterSetName = 'List')]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'Get')][string]$Id,
        [Parameter(ParameterSetName = 'List')][string]$UserId,
        [Parameter(ParameterSetName = 'List')][ValidateSet('processing', 'imported', 'error')][string]$Status,
        [Parameter(ParameterSetName = 'List')][int]$Page = 1,
        [Parameter(ParameterSetName = 'List')][ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    if ($PSCmdlet.ParameterSetName -eq 'Get') {
        $response = Invoke-DbApi -Method 'GET' -Path "/api/v1/expenses/$Id" -ApiKey $ApiKey -BaseUri $BaseUri
        return $response.data
    }
    $q = @{ page = $Page; page_size = $PageSize }
    if ($UserId) { $q['user_id'] = $UserId }
    if ($Status) { $q['status'] = $Status }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/expenses' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function Get-DbAbsenceType {
    <#
    .SYNOPSIS
        Lists the absence-type catalogue, paginated (GET /api/v1/absence-types).
    .DESCRIPTION
        Returns your company's absence-type catalogue: the `key` that each absence's `type.key`
        references, plus the attributes that interpret it (`paid`, `consumes_vacation`,
        `requires_approval`, `requires_justification`, `allows_hourly`, `day_count_mode` = working/natural)
        and `active`. Retired types (`active = $false`) are included so a historical `type.key` always
        resolves. The result has a `data` array and a `meta` object with `page`, `page_size` and `total`.
        Requires a key with the absence-types.list method enabled.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Types per page (default 25, max 100). The catalogue is small.
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `data` (an array of absence types) and `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbAbsenceType
    .EXAMPLE
        (Get-DbAbsenceType).data | ForEach-Object { '{0}: {1}' -f $_.key, $_.label }
    .LINK
        https://dutybeat.com/manuales/api/listar-tipos-ausencia
    #>

    [CmdletBinding()]
    param(
        [int]$Page = 1,
        [ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $q = @{ page = $Page; page_size = $PageSize }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/absence-types' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

function Get-DbHoliday {
    <#
    .SYNOPSIS
        Lists a work center's holidays, paginated (GET /api/v1/holidays).
    .DESCRIPTION
        Returns the festivo calendar of one work center (sede), oldest first. Each holiday has a `date`,
        a `name` and a `type` (national / regional / local). The result has a `data` array and a `meta`
        object with `page`, `page_size` and `total`. Requires a key with the holidays.list method enabled.
    .PARAMETER WorkCenterId
        The work center whose calendar to fetch (required). Discover ids with Get-DbWorkCenter.
    .PARAMETER Year
        Narrow to a single year (YYYY). Omit for the full calendar.
    .PARAMETER Page
        Page to fetch (1-based). Defaults to 1.
    .PARAMETER PageSize
        Holidays per page (default 25, max 100).
    .PARAMETER ApiKey
        API key to use for this call. Defaults to the one from Connect-DutyBeat or DUTYBEAT_API_KEY.
    .PARAMETER BaseUri
        Base URL of the API. Defaults to the session value or the production API.
    .OUTPUTS
        PSCustomObject with `data` (an array of holidays) and `meta` (page, page_size, total).
    .EXAMPLE
        Get-DbHoliday -WorkCenterId 'wc-madrid' -Year '2026'
    .EXAMPLE
        (Get-DbHoliday -WorkCenterId 'wc-madrid').data | ForEach-Object { '{0}: {1}' -f $_.date, $_.name }
    .LINK
        https://dutybeat.com/manuales/api/listar-festivos
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$WorkCenterId,
        [string]$Year,
        [int]$Page = 1,
        [ValidateRange(1, 100)][int]$PageSize = 25,
        [string]$ApiKey,
        [string]$BaseUri
    )
    $q = @{ work_center_id = $WorkCenterId; page = $Page; page_size = $PageSize }
    if ($Year) { $q['year'] = $Year }
    Invoke-DbApi -Method 'GET' -Path '/api/v1/holidays' -Query $q -ApiKey $ApiKey -BaseUri $BaseUri
}

Export-ModuleMember -Function 'Connect-DutyBeat', 'Get-DbMe', 'Get-DbUser', 'Get-DbUsers', 'New-DbUser', 'Set-DbUser', 'Disable-DbUser', 'Get-DbAttendance', 'Get-DbAttendanceSummary', 'Get-DbAbsence', 'Get-DbAbsenceType', 'Get-DbDepartment', 'Get-DbWorkCenter', 'Get-DbExpense', 'Get-DbHoliday'