Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Module\Notifications\Integrations\Exception;

use Exception;

class IncidentNotFoundException extends Exception
{
}
205 changes: 172 additions & 33 deletions library/Notifications/Integrations/Incident.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,116 @@
use DateTime;
use Generator;
use Icinga\Module\Notifications\Common\EntityManager;
use Icinga\Module\Notifications\Integrations\Exception\IncidentNotFoundException;
use Icinga\Module\Notifications\Model\Contact;
use Icinga\Module\Notifications\Model\Incident as IncidentModel;
use Icinga\Module\Notifications\Model\IncidentContact;
use Icinga\Module\Notifications\Model\IncidentHistory;
use Icinga\User;
use InvalidArgumentException;
use ipl\Orm\Query;
use ipl\Sql\Connection;
use ipl\Sql\Expression;
use ipl\Stdlib\Filter;
use LogicException;

/**
* Manage an incident's recipients and read its state
*/
class Incident
{
/** @var IncidentModel The managed incident */
private IncidentModel $incident;
/** @var ?IncidentModel The managed incident, null if it wasn't fetched yet */
private ?IncidentModel $incident = null;

/** @var ?Query<IncidentModel> The query to lazy load the incident */
private ?Query $query = null;

/** @var Connection The database connection to use */
private Connection $db;

private function __construct()
{
}

/**
* Create a new wrapper for the given model
* Create an instance from a query that should return one incident
*
* @param IncidentModel $incident
* @param Connection $db The connection to read and persist through
* If the query does not return an incident, calling any function on the created instance throws an
* {@see IncidentNotFoundException}
*
* @param Query<IncidentModel> $query
*
* @return static
Comment thread
nilmerg marked this conversation as resolved.
*/
public function __construct(IncidentModel $incident, Connection $db)
public static function fromQuery(Query $query): static
{
$this->incident = $incident;
$this->db = $db;
$incident = new static();
$incident->query = $query;
$incident->db = $query->getDb();

return $incident;
}

/**
* Create an instance from an {@see IncidentModel}
*
* Instances created with this factory will never throw an {@see IncidentNotFoundException}
*
* @param IncidentModel $model
* @param Connection $db
*
* @return static
Comment thread
nilmerg marked this conversation as resolved.
*/
public static function fromModel(IncidentModel $model, Connection $db): static
{
$incident = new static();
$incident->incident = $model;
$incident->db = $db;

return $incident;
}

/**
* Get the given user's role for the incident, null if the user has no role, throws if no matching incident exists
*
* @param User $user
*
* @return 'manager'|'subscriber'|'recipient'|null
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
*/
public function getRole(User $user): ?string
Comment thread
nilmerg marked this conversation as resolved.
{
if ($this->incident === null) {
$incidentContactTable = (new IncidentContact())->getTableName();
$contactTable = (new Contact())->getTableName();
$query = $this->consumeQuery()
->withColumns(['role' =>
new Expression(
"(SELECT ic.role FROM $incidentContactTable AS ic"
. " JOIN $contactTable AS c ON ic.contact_id = c.id"
. " WHERE c.username = ? AND c.deleted = 'n' AND ic.incident_id = %s)",
['id'],
$user->getUsername()
)
]);

$this->incident = $query->first();
if ($this->incident === null) {
throw new IncidentNotFoundException('No matching incident was found');
}

return $this->incident->role;
} else {
return IncidentContact::on($this->db)
->columns('role')
->filter(Filter::all(
Filter::equal('incident_id', $this->incident()->id),
Filter::equal('contact.username', $user->getUsername())
))
->first()
?->role;
}
}

/**
Expand All @@ -48,6 +129,7 @@ public function __construct(IncidentModel $incident, Connection $db)
*
* @return $this
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
* @throws InvalidArgumentException If no contact with that username exists
*/
public function addManager(string $username): static
Expand All @@ -66,6 +148,7 @@ public function addManager(string $username): static
*
* @return $this
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
* @throws InvalidArgumentException If no contact with that username exists
*/
public function addSubscriber(string $username): static
Expand All @@ -84,6 +167,7 @@ public function addSubscriber(string $username): static
*
* @return $this
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
* @throws InvalidArgumentException If no contact with that username exists
*/
public function removeManager(string $username): static
Expand All @@ -102,6 +186,7 @@ public function removeManager(string $username): static
*
* @return $this
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
* @throws InvalidArgumentException If no contact with that username exists
*/
public function removeSubscriber(string $username): static
Expand All @@ -122,53 +207,65 @@ public function removeSubscriber(string $username): static
/**
* Yield each active subscriber of the incident
*
* @return Generator<int, array{
* @return array<int, array{
* name: string,
* username: ?string,
* role: 'manager'|'subscriber',
* roleChangedAt: DateTime}>
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
*/
public function getSubscribers(): Generator
public function getSubscribers(): array
{
foreach ($this->resolveRecipients(['manager', 'subscriber']) as $recipient) {
yield [
'name' => $recipient['name'],
'username' => $recipient['username'],
'role' => $recipient['role'],
'roleChangedAt' => $recipient['roleChangedAt'],
];
}
return array_map(
function ($recipient) {
return [
'name' => $recipient['name'],
'username' => $recipient['username'],
'role' => $recipient['role'],
'roleChangedAt' => $recipient['roleChangedAt']
];
},
$this->resolveRecipients(['manager', 'subscriber'])
);
}

/**
* Yield each configured recipient of the incident
*
* @return Generator<int, array{
* @return array<int, array{
* type: 'contact'|'contactgroup'|'schedule',
* name: string,
* username: ?string,
* roleChangedAt: DateTime}>
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
*/
public function getRecipients(): Generator
public function getRecipients(): array
{
foreach ($this->resolveRecipients(['recipient']) as $recipient) {
yield [
'type' => $recipient['type'],
'name' => $recipient['name'],
'username' => $recipient['username'],
'roleChangedAt' => $recipient['roleChangedAt']
];
}
return array_map(
function ($recipient) {
return [
'type' => $recipient['type'],
'name' => $recipient['name'],
'username' => $recipient['username'],
'roleChangedAt' => $recipient['roleChangedAt']
];
},
$this->resolveRecipients(['recipient'])
);
}

/**
* Get whether the incident is muted
*
* @return bool
*
* @throws IncidentNotFoundException If the query passed to {@see static::fromQuery()} has no result
*/
public function isMuted(): bool
{
return $this->incident->mute_reason !== null;
return $this->incident()->mute_reason !== null;
}

/**
Expand Down Expand Up @@ -202,7 +299,7 @@ private function existingContact(int $contactId): ?IncidentContact
/** @var ?IncidentContact $entry */
$entry = IncidentContact::on($this->db)
->filter(Filter::all(
Filter::equal('incident_id', $this->incident->id),
Filter::equal('incident_id', $this->incident()->id),
Filter::equal('contact_id', $contactId)
))
->first()
Expand Down Expand Up @@ -231,7 +328,7 @@ private function resolveRecipients(array $roles): array
->with(['contact', 'contactgroup', 'schedule'])
->filter(
Filter::all(
Filter::equal('incident_id', $this->incident->id),
Filter::equal('incident_id', $this->incident()->id),
Filter::equal('role', $roles)
)
);
Expand Down Expand Up @@ -296,7 +393,7 @@ private function assignRole(string $username, string $role, array $noopRoles): s
(new EntityManager($this->db))->save($existing);
} else {
$incidentContact = (new IncidentContact())->setNew();
$incidentContact->incident_id = $this->incident->id;
$incidentContact->incident_id = $this->incident()->id;
$incidentContact->contact_id = $contact->id;
$incidentContact->role = $role;
(new EntityManager($this->db))->save($incidentContact);
Expand All @@ -319,12 +416,54 @@ private function assignRole(string $username, string $role, array $noopRoles): s
private function addRoleChangedHistory(int $contactId, ?string $oldRole, ?string $newRole): void
{
$history = (new IncidentHistory())->setNew();
$history->incident_id = $this->incident->id;
$history->incident_id = $this->incident()->id;
$history->contact_id = $contactId;
$history->type = 'recipient_role_changed';
$history->old_recipient_role = $oldRole;
$history->new_recipient_role = $newRole;
$history->time = new DateTime();
(new EntityManager($this->db))->save($history);
}

/**
* Fetch the incident lazily and return it
*
* @return IncidentModel
*
* @throws IncidentNotFoundException
*/
private function incident(): IncidentModel
{
if ($this->incident === null) {
$this->incident = $this->consumeQuery()->first();
}

if ($this->incident === null) {
throw new IncidentNotFoundException('No matching incident was found');
}

return $this->incident;
}

/**
* Single use getter for the query to lazy load the incident
*
* @return Query<IncidentModel>
*
* @throws LogicException If the query has already been consumed
*/
private function consumeQuery(): Query
{
if ($this->query === null) {
throw new LogicException(
'Cannot fetch the incident again, the query has already been consumed.'
. 'An earlier call probably failed with an IncidentNotFoundException.'
);
}

$query = $this->query;
$this->query = null;

return $query;
}
}
Loading
Loading