dimescheduler 0.1.0

Official Rust client library for the Dime.Scheduler API.
Documentation
use std::ops::Deref;

use reqwest::Method;
use serde::de::DeserializeOwned;

use crate::api::CrudApi;
use crate::error::Result;
use crate::transport::{encode_segment, Transport};
use crate::types::DimeSchedulerApiResponse;
use crate::{entities, types};

/// Declares a CRUD accessor with extra query methods. The extra struct
/// derefs to its `CrudApi`, so `client.jobs.create(..)` and
/// `client.jobs.get("JOB-1")` both work.
macro_rules! crud_with_extras {
    ($name:ident, $entity:ty, $dto:ty, $route:literal, $doc:literal) => {
        #[doc = $doc]
        #[derive(Clone)]
        pub struct $name {
            crud: CrudApi<$entity, $dto>,
        }

        impl $name {
            pub(crate) fn new(transport: Transport) -> Self {
                Self {
                    crud: CrudApi::new(transport, $route),
                }
            }
        }

        impl Deref for $name {
            type Target = CrudApi<$entity, $dto>;
            fn deref(&self) -> &Self::Target {
                &self.crud
            }
        }
    };
}

crud_with_extras!(
    AppointmentApi,
    entities::Appointment,
    types::AppointmentDto,
    "/appointment",
    "CRUD for appointments plus the date-range query and lookup by id. `get_all` is inherited from `CrudApi` but this route requires query parameters; use `get`."
);
crud_with_extras!(
    JobApi,
    entities::Job,
    types::JobDto,
    "/job",
    "CRUD for jobs plus lookup by job number."
);
crud_with_extras!(
    TaskApi,
    entities::Task,
    types::TaskDto,
    "/task",
    "CRUD for tasks plus the filtered, paginated list query."
);
crud_with_extras!(
    NotificationApi,
    entities::Notification,
    types::NotificationDto,
    "/notification",
    "CRUD for notifications plus the paginated query. `get_all` is inherited from `CrudApi` but this route requires query parameters; use `get`."
);
crud_with_extras!(
    ResourceCapacityApi,
    entities::ResourceCapacity,
    types::ResourceCapacityDto,
    "/resourceCapacity",
    "CRUD for resource capacities plus the date-range query. `get_all` is inherited from `CrudApi` but this route requires query parameters; use `get`."
);
crud_with_extras!(
    TimeEntryApi,
    entities::TimeEntry,
    types::TimeSheetEntryDto,
    "/timeEntry",
    "CRUD for time entries plus the date-range query over time sheet entries. `get_all` is inherited from `CrudApi` but this route requires query parameters; use `get`."
);
crud_with_extras!(
    TimeSessionApi,
    entities::TimeSession,
    types::TimeSessionOverviewDto,
    "/timeSession",
    "CRUD for time sessions plus the date-range query over session overviews. `get_all` is inherited from `CrudApi` but this route requires query parameters; use `get`."
);

impl AppointmentApi {
    /// Appointments within the date range for the given resources.
    /// `start_date` / `end_date` are RFC 3339 timestamps (`2026-01-31T00:00:00Z`).
    /// The API declares `resources` as required; pass an empty slice to omit it.
    pub async fn get(
        &self,
        start_date: &str,
        end_date: &str,
        resources: &[&str],
    ) -> Result<Vec<types::AppointmentDto>> {
        let mut query = vec![
            ("startDate", start_date.to_string()),
            ("endDate", end_date.to_string()),
        ];
        query.extend(resources.iter().map(|r| ("resources", (*r).to_string())));
        self.transport().get_json(self.route(), &query).await
    }

    /// A single appointment by its identifier.
    pub async fn get_by_id(&self, id: &str) -> Result<types::AppointmentDto> {
        self.transport()
            .get_json(&format!("{}/{}", self.route(), encode_segment(id)), &[])
            .await
    }
}

impl JobApi {
    /// A single job by its job number.
    pub async fn get(&self, job_no: &str) -> Result<types::JobDto> {
        self.transport()
            .get_json(&format!("{}/{}", self.route(), encode_segment(job_no)), &[])
            .await
    }
}

/// Optional filters for [`TaskApi::list`]. Unset fields are omitted from the query.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TaskListOptions {
    /// `Some(true)` for open tasks only, `Some(false)` for closed, `None` for all.
    pub is_open: Option<bool>,
    /// 1-based page number.
    pub page: Option<u32>,
    pub page_size: Option<u32>,
}

impl TaskApi {
    /// Tasks, optionally restricted to open tasks and paginated.
    /// `&TaskListOptions::default()` is equivalent to `get_all`.
    pub async fn list(&self, options: &TaskListOptions) -> Result<Vec<types::TaskDto>> {
        let mut query = Vec::new();
        if let Some(open) = options.is_open {
            query.push(("isOpen", open.to_string()));
        }
        if let Some(page) = options.page {
            query.push(("page", page.to_string()));
        }
        if let Some(size) = options.page_size {
            query.push(("pageSize", size.to_string()));
        }
        self.transport().get_json(self.route(), &query).await
    }
}

/// Optional sort / group / filter expressions for [`NotificationApi::get`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NotificationListOptions {
    pub sort: Option<String>,
    pub group: Option<String>,
    pub filter: Option<String>,
}

impl NotificationApi {
    /// A page of notifications. `page` is 1-based.
    pub async fn get(
        &self,
        page: u32,
        limit: u32,
        options: &NotificationListOptions,
    ) -> Result<Vec<types::NotificationDto>> {
        let mut query = vec![("page", page.to_string()), ("limit", limit.to_string())];
        if let Some(s) = &options.sort {
            query.push(("sort", s.clone()));
        }
        if let Some(g) = &options.group {
            query.push(("group", g.clone()));
        }
        if let Some(f) = &options.filter {
            query.push(("filter", f.clone()));
        }
        self.transport().get_json(self.route(), &query).await
    }
}

fn date_range(start: &str, end: &str) -> [(&'static str, String); 2] {
    [("start", start.to_string()), ("end", end.to_string())]
}

impl ResourceCapacityApi {
    /// Resource capacities within the range. `start` / `end` are RFC 3339 timestamps.
    pub async fn get(&self, start: &str, end: &str) -> Result<Vec<types::ResourceCapacityDto>> {
        self.transport()
            .get_json(self.route(), &date_range(start, end))
            .await
    }
}

impl TimeEntryApi {
    /// Time sheet entries dated within the inclusive day range. `start` / `end` are `YYYY-MM-DD`.
    pub async fn get(&self, start: &str, end: &str) -> Result<Vec<types::TimeSheetEntryDto>> {
        self.transport()
            .get_json(self.route(), &date_range(start, end))
            .await
    }
}

impl TimeSessionApi {
    /// Time sessions started within the inclusive day range. `start` / `end` are `YYYY-MM-DD`.
    pub async fn get(&self, start: &str, end: &str) -> Result<Vec<types::TimeSessionOverviewDto>> {
        self.transport()
            .get_json(self.route(), &date_range(start, end))
            .await
    }
}

/// Read-only listing for entities the API exposes no write verbs on
/// (calendars, resource types, appointment dependencies, appointment fields).
#[derive(Clone)]
pub struct ReadOnlyApi<D> {
    transport: Transport,
    route: &'static str,
    _marker: std::marker::PhantomData<fn() -> D>,
}

impl<D: DeserializeOwned> ReadOnlyApi<D> {
    pub(crate) fn new(transport: Transport, route: &'static str) -> Self {
        Self {
            transport,
            route,
            _marker: std::marker::PhantomData,
        }
    }

    /// Lists every record.
    pub async fn get_all(&self) -> Result<Vec<D>> {
        self.transport.get_json(self.route, &[]).await
    }

    /// The API route this accessor targets.
    pub fn route(&self) -> &'static str {
        self.route
    }
}

/// Task budget entries are append-only budget movements: the API exposes
/// create and delete, but no update or list verb.
#[derive(Clone)]
pub struct TaskBudgetEntryApi {
    transport: Transport,
}

impl TaskBudgetEntryApi {
    const ROUTE: &'static str = "/taskBudgetEntry";

    pub(crate) fn new(transport: Transport) -> Self {
        Self { transport }
    }

    pub async fn create(
        &self,
        entry: &entities::TaskBudgetEntry,
    ) -> Result<DimeSchedulerApiResponse> {
        self.transport
            .send_json(Method::POST, Self::ROUTE, entry)
            .await
    }

    pub async fn create_many(
        &self,
        entries: &[entities::TaskBudgetEntry],
    ) -> Result<DimeSchedulerApiResponse> {
        self.transport
            .send_json(Method::POST, Self::ROUTE, entries)
            .await
    }

    pub async fn delete(
        &self,
        entry: &entities::TaskBudgetEntry,
    ) -> Result<DimeSchedulerApiResponse> {
        self.transport
            .send_json(Method::DELETE, Self::ROUTE, entry)
            .await
    }

    pub async fn delete_many(
        &self,
        entries: &[entities::TaskBudgetEntry],
    ) -> Result<DimeSchedulerApiResponse> {
        self.transport
            .send_json(Method::DELETE, Self::ROUTE, entries)
            .await
    }
}

/// Assign a resource to a task, or remove that assignment. Takes one request
/// object per call; the API answers with an empty 200.
#[derive(Clone)]
pub struct TaskAssignmentApi {
    transport: Transport,
}

impl TaskAssignmentApi {
    const ROUTE: &'static str = "/taskAssignment";

    pub(crate) fn new(transport: Transport) -> Self {
        Self { transport }
    }

    pub async fn create(&self, request: &types::TaskAssignmentRequest) -> Result<()> {
        self.transport
            .send_empty(Method::POST, Self::ROUTE, request)
            .await
    }

    pub async fn delete(&self, request: &types::TaskAssignmentRequest) -> Result<()> {
        self.transport
            .send_empty(Method::DELETE, Self::ROUTE, request)
            .await
    }
}