From 1f6839ebbab2c4b62df62653a8fe863ea8a87005 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 01/14] feat(S3): Add RepositoryInterface with find, all, search, create, update, patch, delete, IteratorAggregate contract EPIC-#79 #102 --- src/Core/Contracts/RepositoryInterface.php | 103 +++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/Core/Contracts/RepositoryInterface.php diff --git a/src/Core/Contracts/RepositoryInterface.php b/src/Core/Contracts/RepositoryInterface.php new file mode 100644 index 0000000..8c281d8 --- /dev/null +++ b/src/Core/Contracts/RepositoryInterface.php @@ -0,0 +1,103 @@ +`). The generic parameter is + * covariant so a `RepositoryInterface` can be passed where a + * `RepositoryInterface` is expected. + * + * The interface extends `IteratorAggregate` so that repositories can be used + * in `foreach` loops directly — equivalent to calling {@see self::all()}. + * + * @template-covariant T of DTOInterface + * @extends IteratorAggregate + */ +interface RepositoryInterface extends IteratorAggregate +{ + /** + * Fetches a single resource by server-assigned ID. + * + * @throws \ZammadAPIClient\Exceptions\NotFoundException If no resource with $id exists. + * @throws \ZammadAPIClient\Exceptions\AuthenticationException On authentication failure. + * @return T + */ + public function find(int $id): DTOInterface; + + /** + * Returns all resources, lazily fetched page by page. + * + * The iterable is backed by a generator that yields one page at a time via + * cursor pagination. Do not convert to an array on large datasets without + * applying a limit first. + * + * @param array $query Optional API query parameters (e.g. filters). + * @return iterable + */ + public function all(array $query = []): iterable; + + /** + * Returns resources matching the full-text search term. + * + * Zammad's search endpoint is used; $term is passed verbatim. Additional + * API parameters (e.g. `limit`) can be supplied via $query. + * + * @param array $query Optional API query parameters. + * @return iterable + */ + public function search(string $term, array $query = []): iterable; + + /** + * Creates a new resource and returns the server-confirmed state. + * + * The $dto must not have an ID set. The returned DTO will have the + * server-assigned ID, created_at, and updated_at values populated. + * + * @throws \ZammadAPIClient\Exceptions\ValidationException If the API rejects the payload. + * @return T + */ + public function create(DTOInterface $dto): DTOInterface; + + /** + * Replaces an entire resource with the given DTO (full PUT). + * + * All writable fields of $dto are sent to the API; fields absent in the + * DTO but present on the server will be overwritten with their defaults. + * Use {@see self::patch()} when only a subset of fields should change. + * + * @throws \ZammadAPIClient\Exceptions\NotFoundException If $id does not exist. + * @throws \ZammadAPIClient\Exceptions\ValidationException If the API rejects the payload. + * @return T + */ + public function update(int $id, DTOInterface $dto): DTOInterface; + + /** + * Partially updates a resource (PATCH semantics). + * + * Only the supplied $changes are sent; all other fields remain unchanged on + * the server. $changes may be a plain array or any object that exposes a + * `toPatchArray()` method (e.g. {@see \ZammadAPIClient\Endpoints\Tickets\TicketUpdateDTO}). + * + * @param array|object $changes Fields to update. + * @return T + */ + public function patch(int $id, array|object $changes): DTOInterface; + + /** + * Permanently deletes the resource with the given ID. + * + * Zammad does not support soft-delete via the API; this operation is + * irreversible. No exception is thrown if the resource does not exist + * (the API returns 200 in that case). + * + * @throws \ZammadAPIClient\Exceptions\AuthenticationException On permission errors. + */ + public function delete(int $id): void; +} From 4e5a8ee9710a7e444cff99093fb59f2f625604be Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 02/14] feat(S3): Add AbstractRepository with generator-based paginate(), explicit page methods, resource()/list() Ruby-style accessors, extractItems() EPIC-#79 #103 --- src/Core/AbstractRepository.php | 361 ++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/Core/AbstractRepository.php diff --git a/src/Core/AbstractRepository.php b/src/Core/AbstractRepository.php new file mode 100644 index 0000000..55514f6 --- /dev/null +++ b/src/Core/AbstractRepository.php @@ -0,0 +1,361 @@ +`). + * 2. Implement {@see self::getListKey()} to name the JSON array key that holds + * the resource list in paginated list responses (varies per endpoint). + * 3. Optionally add endpoint-specific convenience methods (e.g. `getForTicket`). + * + * All repositories are instantiated via {@see \ZammadAPIClient\ZammadClient::repo()}, + * which injects the shared `RequestHandler` and the wiring defined in + * {@see \ZammadAPIClient\Core\RepositoryRegistry::DEFINITIONS}. + * + * @template T of DTOInterface + * @implements RepositoryInterface + */ +abstract class AbstractRepository implements RepositoryInterface +{ + /** + * @param RequestHandlerInterface $handler Shared HTTP transport; injected by the client. + * @param string $resourcePath API path segment for this resource (e.g. `tickets`). + * @param class-string $dtoClass DTO class used to hydrate API responses. + * @param int $pageSize Number of items fetched per page during cursor pagination. + */ + public function __construct( + protected RequestHandlerInterface $handler, + protected string $resourcePath, + protected string $dtoClass, + protected int $pageSize = 100, + ) { + } + + /** + * Returns the fully-qualified DTO class name associated with this repository. + * + * Useful when the caller needs to instantiate or inspect the DTO without + * going through a repository method (e.g. in generic utility code). + * + * @return class-string + */ + public function getDtoClass(): string + { + return $this->dtoClass; + } + + /** + * Returns the JSON array key that contains the resource list in paginated responses. + * + * Zammad's list endpoints wrap results in a keyed array (e.g. `{"tickets": [...], "assets": {...}}`). + * Each endpoint uses a different key; subclasses declare the correct one here so + * {@see self::extractItems()} can locate the items without guessing. + */ + abstract protected function getListKey(): string; + + /** + * Fetches a single resource by its Zammad-assigned ID. + * + * The `expand=true` query parameter is appended automatically so that + * Zammad resolves referenced objects (e.g. owner name instead of just ID) + * and returns them inline. Without it some relation fields would be null. + * + * @throws \ZammadAPIClient\Exceptions\NotFoundException If no resource with $id exists. + */ + public function find(int $id): DTOInterface + { + return $this->dtoClass::fromArray( + $this->handler->get("{$this->resourcePath}/{$id}", ['expand' => 'true']), + ); + } + + /** + * Ruby-style stateful resource: fetch by ID, returns a mutable wrapper + * with changes tracking. + * + * $ticket = $client->ticket()->resource(1); + * $ticket->title = 'New'; + * $ticket->save(); + * + * @return Resource Resource wrapper with changes tracking + */ + public function resource(int $id): Resource + { + $dto = $this->dtoClass::fromArray( + $this->handler->get("{$this->resourcePath}/{$id}", ['expand' => 'true']), + ); + + return new Resource($dto, $this->handler, $this->resourcePath); + } + + /** + * Ruby-style paginated list with page navigation. + * + * $list = $client->ticket()->list(); + * $list->page(2); + * $list->each(fn($t) => echo $t->title); + * + * @param array $query + * @return PaginatedList + */ + public function list(array $query = []): PaginatedList + { + return new PaginatedList( + $this->handler, + $this->dtoClass, + $this->resourcePath, + $query, + $this->pageSize, + $this->getListKey(), + ); + } + + /** + * Ruby-style search list with page navigation. + * + * @param array $query + * @return PaginatedList + */ + public function searchList(string $term, array $query = []): PaginatedList + { + return new PaginatedList( + $this->handler, + $this->dtoClass, + "{$this->resourcePath}/search", + array_merge(['query' => $term], $query), + $this->pageSize, + $this->getListKey(), + ); + } + + /** + * Streams all resources using transparent cursor pagination. + * + * Pages are fetched lazily via a PHP generator: no data is loaded until + * the caller iterates. A page is considered complete when fewer items than + * $pageSize are returned, at which point iteration stops. This avoids an + * extra request to check for an empty last page. + * + * @param array $query Optional API query parameters applied to every page request. + * @return Generator + */ + public function all(array $query = []): iterable + { + return $this->paginate("{$this->resourcePath}", $query); + } + + /** + * Searches resources using Zammad's full-text search and paginates lazily. + * + * $term is forwarded verbatim as the `query` API parameter. Additional + * parameters (e.g. `limit`, `sort_by`) can be passed via $query. + * + * @param array $query Optional API query parameters merged with the search term. + * @return Generator + */ + public function search(string $term, array $query = []): iterable + { + return $this->paginate( + "{$this->resourcePath}/search", + array_merge(['query' => $term], $query), + ); + } + + /** + * Fetches exactly one explicit page of resources. + * + * Unlike {@see self::all()}, which uses transparent cursor pagination, this + * method exposes the raw `page` / `per_page` parameters so that the legacy + * client can replicate v5's explicit pagination behaviour. + * + * Not intended for use in new code — prefer {@see self::all()}. + * + * @param array $query Optional API query parameters. + * @return array + */ + public function allPage(int $page, int $perPage, array $query = []): array + { + $params = array_merge($query, [ + 'page' => (string) $page, + 'per_page' => (string) $perPage, + ]); + + return $this->hydrateList($this->handler->get($this->resourcePath, $params)); + } + + /** + * Fetches exactly one explicit page of search results. + * + * The legacy counterpart to {@see self::search()}; exists to support v5's + * explicit pagination. Not intended for new code. + * + * @param array $query Optional API query parameters merged with the search term. + * @return array + */ + public function searchPage(string $term, int $page, int $perPage, array $query = []): array + { + $params = array_merge(['query' => $term], $query, [ + 'page' => (string) $page, + 'per_page' => (string) $perPage, + ]); + + return $this->hydrateList($this->handler->get("{$this->resourcePath}/search", $params)); + } + + /** + * @param array $query + * @return Generator + */ + private function paginate(string $endpoint, array $query): Generator + { + $page = 1; + + do { + $params = array_merge($query, ['page' => (string) $page, 'per_page' => (string) $this->pageSize]); + $items = $this->extractItems($this->handler->get($endpoint, $params)); + + foreach ($items as $item) { + yield $this->dtoClass::fromArray($item); + } + + $hasMore = count($items) === $this->pageSize; + $page++; + } while ($hasMore); + } + + /** + * @param array $data + * @return array + */ + protected function hydrateList(array $data): array + { + $result = []; + + foreach ($this->extractItems($data) as $item) { + $result[] = $this->dtoClass::fromArray($item); + } + + return $result; + } + + /** + * @param array $data + * @return array> + */ + protected function extractItems(array $data, ?string $key = null): array + { + $items = $data[$key ?? $this->getListKey()] ?? []; + + if (!is_array($items)) { + return []; + } + + /** @var array> $filtered */ + $filtered = array_values(array_filter($items, 'is_array')); + + return $filtered; + } + + /** + * Allows using the repository directly in a `foreach` loop. + * + * Delegates to {@see self::all()} so `foreach ($repo as $dto)` is + * equivalent to `foreach ($repo->all() as $dto)`. + */ + public function getIterator(): Traversable + { + return $this->all(); + } + + /** + * Creates a new resource and returns the server-confirmed DTO. + * + * The $dto must not have an ID (id should be null). The returned DTO + * will have the server-assigned `id`, `created_at`, and `updated_at` set. + * + * @throws \ZammadAPIClient\Exceptions\ValidationException If the payload is rejected by the API. + */ + public function create(DTOInterface $dto): DTOInterface + { + return $this->dtoClass::fromArray($this->handler->post($this->resourcePath, $dto->toArray())); + } + + /** + * Replaces a resource entirely via a full PUT request. + * + * All writable fields from $dto are sent; server-assigned fields + * (`id`, `created_at`, `updated_at`) included in `toArray()` are silently + * ignored by the Zammad API. The returned DTO is hydrated from the + * server's response, reflecting any server-side transformations. + * + * For partial updates (only a few fields), use {@see self::patch()} to + * avoid accidentally overwriting fields the caller did not intend to change. + * + * @throws \ZammadAPIClient\Exceptions\NotFoundException If $id does not exist. + * @throws \ZammadAPIClient\Exceptions\ValidationException If the payload is rejected. + */ + public function update(int $id, DTOInterface $dto): DTOInterface + { + return $this->dtoClass::fromArray( + $this->handler->put("{$this->resourcePath}/{$id}", $dto->toArray()), + ); + } + + /** + * Partially updates a resource (PUT with a reduced field set). + * + * $changes may be: + * - A plain `array`: only non-null values are sent. + * - An object with a `toPatchArray()` method (e.g. `TicketUpdateDTO`): + * the method's return value is sent verbatim, allowing explicit nulling. + * - Any other object: its public properties are serialised via + * `get_object_vars()` and null values are filtered out. + * + * Zammad uses PUT for both full and partial updates; only the supplied + * fields are changed on the server side because Zammad merges the body + * with the existing resource. + * + * @param array|object $changes Fields to change. + */ + public function patch(int $id, array|object $changes): DTOInterface + { + if (is_object($changes) && method_exists($changes, 'toPatchArray')) { + /** @var array $body */ + $body = $changes->toPatchArray(); + } elseif (is_object($changes)) { + $body = array_filter(get_object_vars($changes), fn($v) => $v !== null); + } else { + $body = array_filter($changes, fn($v) => $v !== null); + } + + return $this->dtoClass::fromArray($this->handler->put("{$this->resourcePath}/{$id}", $body)); + } + + /** + * Permanently deletes the resource with the given ID. + * + * The operation is irreversible. Zammad returns HTTP 200 with the deleted + * resource's data; this method discards that response body. No exception is + * raised if the resource does not exist (Zammad returns 200 regardless). + * + * @throws \ZammadAPIClient\Exceptions\AuthenticationException If the caller lacks permission. + */ + public function delete(int $id): void + { + $this->handler->delete("{$this->resourcePath}/{$id}"); + } +} From a6d946eecaeb17dcfb5dfbfdbf8e33a11dfd0f80 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 03/14] feat(S3): Add RepositoryRegistry as single source of truth mapping 9 repository classes to API paths and DTO classes EPIC-#79 #104 --- src/Core/RepositoryRegistry.php | 66 +++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/Core/RepositoryRegistry.php diff --git a/src/Core/RepositoryRegistry.php b/src/Core/RepositoryRegistry.php new file mode 100644 index 0000000..2057fa2 --- /dev/null +++ b/src/Core/RepositoryRegistry.php @@ -0,0 +1,66 @@ +}> */ + public const DEFINITIONS = [ + TicketRepository::class => ['path' => 'tickets', 'dto' => TicketDTO::class], + UserRepository::class => ['path' => 'users', 'dto' => UserDTO::class], + OrganizationRepository::class => ['path' => 'organizations', 'dto' => OrganizationDTO::class], + GroupRepository::class => ['path' => 'groups', 'dto' => GroupDTO::class], + TicketArticleRepository::class => ['path' => 'ticket_articles', 'dto' => TicketArticleDTO::class], + TicketStateRepository::class => ['path' => 'ticket_states', 'dto' => TicketStateDTO::class], + TicketPriorityRepository::class => ['path' => 'ticket_priorities', 'dto' => TicketPriorityDTO::class], + TagRepository::class => ['path' => 'tags', 'dto' => TagDTO::class], + TextModuleRepository::class => ['path' => 'text_modules', 'dto' => TextModuleDTO::class], + ]; + + /** + * Returns the API path and DTO class wired to the given repository. + * + * Used by {@see \ZammadAPIClient\ZammadClient::repo()} to instantiate a + * repository with the correct $resourcePath and $dtoClass arguments. + * + * @param class-string $repositoryClass Repository class whose wiring is requested. + * @return array{path: string, dto: class-string} + * @throws \InvalidArgumentException If $repositoryClass is not registered in DEFINITIONS. + */ + public static function definition(string $repositoryClass): array + { + if (!array_key_exists($repositoryClass, self::DEFINITIONS)) { + throw new InvalidArgumentException("Unknown repository: {$repositoryClass}"); + } + + return self::DEFINITIONS[$repositoryClass]; + } +} From fc0254bc1345e7ec2cccb62c70fe4b35a793f478 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 04/14] feat(S3): Add PaginatedList (ArrayAccess, Iterator, page navigation) and Resource wrapper (mutable stateful entity with change tracking, save, destroy) EPIC-#79 #105 --- src/Core/PaginatedList.php | 186 +++++++++++++++++++++++++++++++++++++ src/Core/Resource.php | 145 +++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 src/Core/PaginatedList.php create mode 100644 src/Core/Resource.php diff --git a/src/Core/PaginatedList.php b/src/Core/PaginatedList.php new file mode 100644 index 0000000..2fe4a36 --- /dev/null +++ b/src/Core/PaginatedList.php @@ -0,0 +1,186 @@ + + * @implements Iterator + */ +final class PaginatedList implements ArrayAccess, Countable, Iterator +{ + /** @var list */ + private array $items = []; + + private int $position = 0; + + /** + * @param RequestHandlerInterface $handler + * @param class-string $dtoClass + * @param string $endpoint URL path (e.g. 'tickets', 'tickets/search') + * @param array $baseQuery Base query params (e.g. ['query' => 'term']) + * @param int $perPage + */ + public function __construct( + private RequestHandlerInterface $handler, + private string $dtoClass, + private string $endpoint, + private array $baseQuery = [], + private int $perPage = 100, + private ?string $listKey = null, + ) { + } + + /** @return T|null */ + public function first(): ?DTOInterface + { + return $this->offsetGet(0); + } + + /** @return T|null */ + public function offsetGet(mixed $offset): mixed + { + if (!is_int($offset)) { + return null; + } + + $page = (int) floor($offset / $this->perPage) + 1; + $index = $offset % $this->perPage; + + $items = $this->fetchPage($page); + + return $items[$index] ?? null; + } + + public function offsetExists(mixed $offset): bool + { + return $this->offsetGet($offset) !== null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \RuntimeException('PaginatedList is read-only.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new \RuntimeException('PaginatedList is read-only.'); + } + + /** @return self */ + public function page(int $number): self + { + $this->items = $this->fetchPage($number); + $this->position = 0; + + return $this; + } + + /** @return self */ + public function pageNext(): self + { + $currentPage = $this->position > 0 + ? (int) ceil(count($this->items) > 0 ? (array_key_last($this->items) + 1) / $this->perPage : 1) + : 1; + + return $this->page($currentPage + 1); + } + + /** @return self */ + public function pagePrev(): self + { + $currentPage = $this->position > 0 + ? (int) ceil(count($this->items) > 0 ? (array_key_last($this->items) + 1) / $this->perPage : 1) + : 2; + + return $this->page(max(1, $currentPage - 1)); + } + + public function each(callable $callback): void + { + foreach ($this as $item) { + $callback($item); + } + } + + public function count(): int + { + return count($this->items); + } + + /** @return T|null */ + public function current(): mixed + { + return $this->items[$this->position] ?? null; + } + + public function key(): mixed + { + return $this->position; + } + + public function next(): void + { + $this->position++; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + $this->ensurePageLoaded(); + + return $this->position < count($this->items); + } + + private function ensurePageLoaded(): void + { + if (empty($this->items)) { + $this->items = $this->fetchPage(1); + } + } + + /** + * @param int $page + * @return list + */ + private function fetchPage(int $page): array + { + $params = array_merge($this->baseQuery, [ + 'page' => (string) $page, + 'per_page' => (string) $this->perPage, + ]); + + $data = $this->handler->get($this->endpoint, $params); + + $items = $data[$this->listKey ?? $this->inferListKey()] ?? []; + + if (!is_array($items)) { + return []; + } + + /** @var list */ + return array_map( + fn(array $item): DTOInterface => $this->dtoClass::fromArray($item), + array_values(array_filter($items, 'is_array')), + ); + } + + private function inferListKey(): string + { + $parts = explode('/', trim($this->endpoint, '/')); + + return end($parts); + } +} diff --git a/src/Core/Resource.php b/src/Core/Resource.php new file mode 100644 index 0000000..4f09e88 --- /dev/null +++ b/src/Core/Resource.php @@ -0,0 +1,145 @@ +ticket()->resource(1); + * $resource->title = 'New Title'; // tracked in changes + * $resource->state_id = 3; // tracked in changes + * $resource->save(); // PUT only {title, state_id} + */ +final class Resource +{ + /** @var array */ + private array $attributes; + + /** @var array */ + private array $changes = []; + + private bool $newRecord; + + /** + * @param DTOInterface $dto Underlying immutable DTO (the source of truth). + * @param RequestHandlerInterface $handler For API calls (save, destroy). + * @param string $path API path (e.g. 'tickets'). + */ + public function __construct( + private DTOInterface $dto, + private RequestHandlerInterface $handler, + private string $path, + ) { + $this->attributes = $dto->toArray(); + $this->newRecord = $dto->id() === null; + } + + public function __get(string $name): mixed + { + return $this->attributes[$name] ?? null; + } + + public function __set(string $name, mixed $value): void + { + $old = $this->attributes[$name] ?? null; + $this->attributes[$name] = $value; + $this->changes[$name] = ['old' => $old, 'new' => $value]; + } + + public function __isset(string $name): bool + { + return array_key_exists($name, $this->attributes); + } + + /** @return array */ + public function toArray(): array + { + return $this->attributes; + } + + public function id(): ?int + { + $value = $this->attributes['id'] ?? null; + + return is_scalar($value) ? (int) $value : null; + } + + public function newRecord(): bool + { + return $this->newRecord; + } + + public function changed(): bool + { + return !empty($this->changes); + } + + /** @return array */ + public function changes(): array + { + return $this->changes; + } + + /** + * Persists the resource to the Zammad API. + * + * - New record: POST to create. + * - Existing record with changes: PUT only changed fields. + * - Existing record without changes: no request. + */ + public function save(): void + { + if ($this->newRecord) { + $data = $this->handler->post($this->path, $this->attributes); + } elseif ($this->changed()) { + $diff = []; + foreach ($this->changes as $field => $change) { + $diff[$field] = $change['new']; + } + $data = $this->handler->put("{$this->path}/{$this->id()}", $diff); + } else { + return; + } + + if (isset($data['article'])) { + unset($data['article']); + } + + $this->attributes = $data; + $this->changes = []; + $this->newRecord = false; + } + + /** + * Deletes the resource via DELETE request. + */ + public function destroy(): void + { + if ($this->newRecord) { + throw new \RuntimeException('Cannot destroy a new record.'); + } + + $this->handler->delete("{$this->path}/{$this->id()}"); + } + + /** + * Returns the underlying DTO (rebuilds from current attributes). + * + * @return DTOInterface + */ + public function toDTO(): DTOInterface + { + $class = get_class($this->dto); + + return $class::fromArray($this->attributes); + } +} From 56c6d5bb1f84c195c2b7128d69719efd577d14b6 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 05/14] feat(S3): Add TicketRepository extending AbstractRepository with getTicketArticles() and TicketDTO wiring EPIC-#79 #106 --- src/Endpoints/Tickets/TicketRepository.php | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/Endpoints/Tickets/TicketRepository.php diff --git a/src/Endpoints/Tickets/TicketRepository.php b/src/Endpoints/Tickets/TicketRepository.php new file mode 100644 index 0000000..31a1912 --- /dev/null +++ b/src/Endpoints/Tickets/TicketRepository.php @@ -0,0 +1,31 @@ + + */ +final class TicketRepository extends AbstractRepository +{ + /** + * Returns 'tickets' — the JSON array key in Zammad's paginated ticket list response. + * + * Zammad wraps results in `{"tickets": [...], "assets": {...}}`; this key + * tells {@see \ZammadAPIClient\Core\AbstractRepository::extractItems()} where to find the items. + */ + protected function getListKey(): string + { + return 'tickets'; + } +} From d2e572f654d0d3c980c64322ef08e059029aee84 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 06/14] feat(S3): Add UserRepository extending AbstractRepository with import() for bulk CSV import EPIC-#79 #107 --- src/Endpoints/Users/UserRepository.php | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/Endpoints/Users/UserRepository.php diff --git a/src/Endpoints/Users/UserRepository.php b/src/Endpoints/Users/UserRepository.php new file mode 100644 index 0000000..b83af24 --- /dev/null +++ b/src/Endpoints/Users/UserRepository.php @@ -0,0 +1,45 @@ + + */ +final class UserRepository extends AbstractRepository +{ + /** + * Returns 'users' — the JSON array key in Zammad's paginated user list response. + */ + protected function getListKey(): string + { + return 'users'; + } + + /** + * Bulk-imports users from a CSV string. + * + * The CSV format must conform to Zammad's import specification. Zammad + * validates each row and skips invalid records rather than aborting the + * entire import. The response body contains an import summary. + * + * Useful for initial data migration or scheduled synchronisation with an + * external identity provider (when LDAP sync is not an option). + * + * @param string $csv Raw CSV content, including the header row. + */ + public function import(string $csv): void + { + $this->handler->post("{$this->resourcePath}/import", ['data' => $csv]); + } +} From 3db333e86127c3dfc96ea4907703f68af21e6066 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 07/14] feat(S3): Add OrganizationRepository extending AbstractRepository with import() for bulk CSV import EPIC-#79 #108 --- .../Organizations/OrganizationRepository.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/Endpoints/Organizations/OrganizationRepository.php diff --git a/src/Endpoints/Organizations/OrganizationRepository.php b/src/Endpoints/Organizations/OrganizationRepository.php new file mode 100644 index 0000000..0a5eff0 --- /dev/null +++ b/src/Endpoints/Organizations/OrganizationRepository.php @@ -0,0 +1,44 @@ + + */ +final class OrganizationRepository extends AbstractRepository +{ + /** + * Returns 'organizations' — the JSON array key in Zammad's paginated organization list response. + */ + protected function getListKey(): string + { + return 'organizations'; + } + + /** + * Bulk-imports organizations from a CSV string. + * + * The CSV format must conform to Zammad's import specification (see Zammad + * documentation for required columns). Zammad processes the import + * asynchronously; the response body is typically empty on success. + * + * This endpoint is useful for migrating organization data from another + * helpdesk system or synchronising from an external directory. + * + * @param string $csv Raw CSV content, including the header row. + */ + public function import(string $csv): void + { + $this->handler->post("{$this->resourcePath}/import", ['data' => $csv]); + } +} From efc6aaac30d538dbe77659ce45f2b0c3158d00be Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 08/14] feat(S3): Add GroupRepository extending AbstractRepository with GroupDTO wiring EPIC-#79 #109 --- src/Endpoints/Groups/GroupRepository.php | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/Endpoints/Groups/GroupRepository.php diff --git a/src/Endpoints/Groups/GroupRepository.php b/src/Endpoints/Groups/GroupRepository.php new file mode 100644 index 0000000..902f4ed --- /dev/null +++ b/src/Endpoints/Groups/GroupRepository.php @@ -0,0 +1,30 @@ + + */ +final class GroupRepository extends AbstractRepository +{ + /** + * Returns 'groups' — the JSON array key used in Zammad's paginated group list response. + * + * Zammad wraps paginated results in `{"groups": [...], "assets": {...}}`; + * this key tells {@see \ZammadAPIClient\Core\AbstractRepository::extractItems()} where to find the items. + */ + protected function getListKey(): string + { + return 'groups'; + } +} From 682eae57cfcc89be3cd9a5c872a66dc6c4bb7ced Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 09/14] feat(S3): Add TicketArticleRepository extending AbstractRepository with getForTicket() and getAttachmentContent() EPIC-#79 #110 --- .../TicketArticleRepository.php | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/Endpoints/TicketArticles/TicketArticleRepository.php diff --git a/src/Endpoints/TicketArticles/TicketArticleRepository.php b/src/Endpoints/TicketArticles/TicketArticleRepository.php new file mode 100644 index 0000000..a500e10 --- /dev/null +++ b/src/Endpoints/TicketArticles/TicketArticleRepository.php @@ -0,0 +1,88 @@ + + */ +final class TicketArticleRepository extends AbstractRepository +{ + /** + * Returns 'ticket_articles' — the JSON array key in Zammad's article list response. + * + * Zammad wraps paginated results in `{"ticket_articles": [...], "assets": {...}}`. + */ + protected function getListKey(): string + { + return 'ticket_articles'; + } + + /** + * Streams all articles belonging to the given ticket, including relation data. + * + * Uses the dedicated `/ticket_articles/by_ticket/{ticketId}` endpoint rather + * than the generic `all()` path because Zammad does not support filtering + * the generic article list by ticket ID in a paginated way. The `expand=true` + * parameter is appended so that author names and other relations are inlined. + * + * @param int $ticketId Zammad ticket ID whose articles should be fetched. + * @return \Generator + */ + public function getForTicket(int $ticketId): iterable + { + $page = 1; + + do { + $params = ['page' => (string) $page, 'per_page' => (string) $this->pageSize, 'expand' => 'true']; + $data = $this->handler->get("ticket_articles/by_ticket/{$ticketId}", $params); + $items = $this->extractItems($data); + + foreach ($items as $item) { + yield TicketArticleDTO::fromArray($item); + } + + $hasMore = count($items) === $this->pageSize; + $page++; + } while ($hasMore); + } + + /** + * Downloads the raw binary content of a ticket attachment. + * + * Attachment content is served by the dedicated `/ticket_attachment/{ticket}/{article}/{attachment}` + * endpoint. The response is binary (e.g. PDF, image); this method returns it as a raw + * string without JSON decoding. Store or stream the result directly. + * + * All three IDs are required because Zammad uses them for permission checks: + * the article must belong to the ticket, and the attachment to the article. + * + * @param int $ticketId ID of the parent ticket. + * @param int $articleId ID of the article that contains the attachment. + * @param int $attachmentId ID of the specific attachment (from the article's `attachments` array). + * @return string Raw binary content of the attachment. + */ + public function getAttachmentContent( + int $ticketId, + int $articleId, + int $attachmentId, + ): string { + $uri = "ticket_attachment/{$ticketId}/{$articleId}/{$attachmentId}"; + + return $this->handler->getRaw($uri); + } +} From 8ee76f7b89189c2423f13eac6cef2ceaa6855bbc Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 10/14] feat(S3): Add TicketStateRepository extending AbstractRepository with TicketStateDTO wiring EPIC-#79 #111 --- .../TicketStates/TicketStateRepository.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/Endpoints/TicketStates/TicketStateRepository.php diff --git a/src/Endpoints/TicketStates/TicketStateRepository.php b/src/Endpoints/TicketStates/TicketStateRepository.php new file mode 100644 index 0000000..8227d2a --- /dev/null +++ b/src/Endpoints/TicketStates/TicketStateRepository.php @@ -0,0 +1,29 @@ + + */ +final class TicketStateRepository extends AbstractRepository +{ + /** + * Returns 'ticket_states' — the JSON array key in Zammad's paginated state list response. + */ + protected function getListKey(): string + { + return 'ticket_states'; + } +} From 343363e8b7b643d0a91cac92aa7b348ec0f3100c Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 11/14] feat(S3): Add TagRepository extending AbstractRepository with add(), remove(), and tagSearch() convenience methods EPIC-#79 #112 --- src/Endpoints/Tags/TagRepository.php | 159 +++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/Endpoints/Tags/TagRepository.php diff --git a/src/Endpoints/Tags/TagRepository.php b/src/Endpoints/Tags/TagRepository.php new file mode 100644 index 0000000..4d1d20a --- /dev/null +++ b/src/Endpoints/Tags/TagRepository.php @@ -0,0 +1,159 @@ + + */ +final class TagRepository extends AbstractRepository +{ + /** + * Returns 'tags' — the JSON array key in Zammad's tag list response. + */ + protected function getListKey(): string + { + return 'tags'; + } + + /** + * Streams tags for a specific object, paginated. + * + * Overrides the generic `all()` because Zammad's tag list endpoint requires + * `object` (the object type name, e.g. `'Ticket'`) and `o_id` (the object's + * numeric ID) to scope the result. Without these parameters the API returns + * an empty list. Defaults to `object=Ticket, o_id=1` when not provided in $query. + * + * @param array $query May include `'object'` (string) and `'o_id'` (int or string). + * @return Generator + */ + public function all(array $query = []): iterable + { + $object = $query['object'] ?? 'Ticket'; + $oId = $query['o_id'] ?? '1'; + + $page = 1; + + do { + $params = array_merge($query, [ + 'page' => (string) $page, + 'per_page' => (string) $this->pageSize, + 'object' => $object, + 'o_id' => $oId, + ]); + $items = $this->extractItems($this->handler->get('tags', $params)); + + foreach ($items as $item) { + yield TagDTO::fromArray($item); + } + + $hasMore = count($items) === $this->pageSize; + $page++; + } while ($hasMore); + } + + /** + * Searches tags globally by prefix and yields matching TagDTOs. + * + * Redirects to the `/tag_search` autocomplete endpoint (via + * {@see self::tagSearch()}) instead of the standard search path, because + * Zammad's tag endpoint does not support generic full-text search. The + * raw response from `tagSearch` is an array of name strings; this method + * wraps each as a `TagDTO` for a consistent iterable API. + * + * @param array $query Unused (tag search has no extra params). + * @return Generator + */ + public function search(string $term, array $query = []): iterable + { + foreach ($this->tagSearch($term) as $item) { + if (is_array($item)) { + /** @var array $item */ + yield TagDTO::fromArray($item); + } + } + } + + /** + * Attaches a tag to a specific Zammad object. + * + * Sends a POST to `/tags/add`. If the tag does not exist in Zammad's tag + * list yet, Zammad creates it automatically. The response contains the + * updated tag state for the object. + * + * @param string $objectType Zammad object class name (e.g. `'Ticket'`). + * @param int $objectId Numeric ID of the object to tag. + * @param string $tag Tag label to attach (case-insensitive in Zammad). + * @return array + */ + public function add( + string $objectType, + int $objectId, + string $tag, + ): array { + return $this->handler->post('tags/add', [ + 'object' => $objectType, + 'o_id' => $objectId, + 'item' => $tag, + ]); + } + + /** + * Detaches a tag from a specific Zammad object. + * + * Sends a DELETE to `/tags/remove` with the object context in the query + * string. The $tag value is URL-encoded to handle special characters safely. + * Removing a tag that is not attached to the object is a no-op on the server. + * + * @param string $objectType Zammad object class name (e.g. `'Ticket'`). + * @param int $objectId Numeric ID of the object to untag. + * @param string $tag Tag label to detach. + * @return array + */ + public function remove( + string $objectType, + int $objectId, + string $tag, + ): array { + $uri = "tags/remove?object={$objectType}&o_id={$objectId}&item=" . urlencode($tag); + + return $this->handler->delete($uri); + } + + /** + * Autocomplete search across all tags in the Zammad instance. + * + * Calls the dedicated `/tag_search` endpoint, which returns tag names + * matching the $term prefix. This endpoint is separate from the tag list + * because it searches across all taggable objects, not just a specific one. + * + * The raw response format is `[{"id": 1, "value": "bug"}, ...]`; callers + * who need typed DTOs should use {@see self::search()} instead. + * + * @return array Raw tag search result from the API. + */ + public function tagSearch(string $term): array + { + return $this->handler->get('tag_search', ['term' => $term]); + } +} From 4be31f778a5a020068bea56d936fb3bb61bc3a18 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 12/14] feat(S3): Add TextModuleRepository extending AbstractRepository with import() for bulk CSV import EPIC-#79 #113 --- .../TextModules/TextModuleRepository.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/Endpoints/TextModules/TextModuleRepository.php diff --git a/src/Endpoints/TextModules/TextModuleRepository.php b/src/Endpoints/TextModules/TextModuleRepository.php new file mode 100644 index 0000000..3b83694 --- /dev/null +++ b/src/Endpoints/TextModules/TextModuleRepository.php @@ -0,0 +1,42 @@ + + */ +final class TextModuleRepository extends AbstractRepository +{ + /** + * Returns 'text_modules' — the JSON array key in Zammad's paginated text-module list response. + */ + protected function getListKey(): string + { + return 'text_modules'; + } + + /** + * Bulk-imports text modules from a CSV string. + * + * Useful for seeding a new Zammad instance with an existing canned-response + * library or synchronising templates managed in an external CMS. The CSV + * format must match Zammad's expected schema (keyword, name, content, etc.). + * + * @param string $csv Raw CSV content, including the header row. + */ + public function import(string $csv): void + { + $this->handler->post("{$this->resourcePath}/import", ['data' => $csv]); + } +} From a4d9509785d49b15ff7dd17236a6657ec8b63bd0 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 13/14] feat(S3): Add TicketPriorityRepository extending AbstractRepository with TicketPriorityDTO wiring EPIC-#79 #115 --- .../TicketPriorityRepository.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/Endpoints/TicketPriorities/TicketPriorityRepository.php diff --git a/src/Endpoints/TicketPriorities/TicketPriorityRepository.php b/src/Endpoints/TicketPriorities/TicketPriorityRepository.php new file mode 100644 index 0000000..bd1e613 --- /dev/null +++ b/src/Endpoints/TicketPriorities/TicketPriorityRepository.php @@ -0,0 +1,28 @@ + + */ +final class TicketPriorityRepository extends AbstractRepository +{ + /** + * Returns 'ticket_priorities' — the JSON array key in Zammad's paginated priority list response. + */ + protected function getListKey(): string + { + return 'ticket_priorities'; + } +} From c703299cf74699035d6401b3fcf00d9c9f800396 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:37:20 +0200 Subject: [PATCH 14/14] =?UTF-8?q?feat(S3):=20Complete=20repository=20layer?= =?UTF-8?q?=20for=20all=209=20resources=20=E2=80=94=20CRUD,=20generator-ba?= =?UTF-8?q?sed=20pagination,=20PaginatedList=20wrapping,=20Ruby-style=20Re?= =?UTF-8?q?source=20mutability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EPIC-#79 #82