diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 000000000..002b57133 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,65 @@ + +## Webhook Events + +Currently, Deck sends the following events that can be received by the [`webhook_listener`](https://docs.nextcloud.com/server/latest/admin_manual/webhook_listeners/index.html) app for Nextcloud Flow automations: + +### `CardCreatedEvent` + +Fired when a new card is created. Payload: + +```text +{ + "title": string, + "description": string, + "boardId": int, + "stackId": int, + "lastModified": string, + "createdAt": string + "labels": [ + { + "id": int, + "title": string + }, + ], + "assignedUsers": string[], + "order": int, + "archived": bool, + "commentsUnread": int, + "commentsCount": int, + "owner": string | null, + "lastEditor": string | null, + "duedate": string | null, + "doneAt": string | null, + "deletedAt": string | null +} +``` + +Note: All timestamps are in ISO8601 format: `2025-01-11T12:34:56+00:00` + +### `CardUpdatedEvent` + +Fired when a card is changed. Contains the values before and after the update. Payload: + +```text +{ + "before": { + //...same format as CardCreatedEvent... + }, + "after": { + //...same format as CardCreatedEvent... + } +} +``` + +### `CardDeletedEvent` + +Fired when a card is deleted. Payload: + +```text +{ + //...same as CardCreatedEvent... +} +``` diff --git a/lib/Db/Card.php b/lib/Db/Card.php index 6813c0167..26ae81cca 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -11,6 +11,7 @@ use DateTime; use DateTimeZone; +use OCA\Deck\Model\Public\CardEventData; use Sabre\VObject\Component\VCalendar; /** @@ -175,4 +176,31 @@ public function getCalendarPrefix(): string { public function getETag(): string { return md5((string)$this->getLastModified()); } + + public function toEventData(): CardEventData { + return new CardEventData( + title: $this->getTitle(), + description: $this->getDescription(), + boardId: $this->getRelatedBoard()->getId(), + stackId: $this->getStackId(), + lastModified: new DateTime('@' . $this->getLastModified()), + createdAt: new DateTime('@' . $this->getCreatedAt()), + labels: array_map(fn ($label) => [ + 'id' => $label->getId(), + 'title' => $label->getTitle() + ], $this->getLabels() ?? []), + assignedUsers: $this->getAssignedUsers() ? array_map(fn ($user) => $user->getUID(), $this->getAssignedUsers()) : [], + order: $this->getOrder(), + archived: $this->getArchived(), + commentsUnread: $this->getCommentsUnread(), + commentsCount: $this->getCommentsCount(), + owner: $this->getOwner(), + lastEditor: $this->getLastEditor(), + duedate: $this->getDuedate(), + doneAt: $this->getDone(), + deletedAt: ($this->getDeletedAt() > 0) + ? new DateTime('@' . $this->getDeletedAt()) + : null + ); + } } diff --git a/lib/Event/CardCreatedEvent.php b/lib/Event/CardCreatedEvent.php index 04606de15..e547aaa7b 100644 --- a/lib/Event/CardCreatedEvent.php +++ b/lib/Event/CardCreatedEvent.php @@ -10,5 +10,10 @@ namespace OCA\Deck\Event; -class CardCreatedEvent extends ACardEvent { +use OCP\EventDispatcher\IWebhookCompatibleEvent; + +class CardCreatedEvent extends ACardEvent implements IWebhookCompatibleEvent { + public function getWebhookSerializable(): array { + return $this->getCard()->toEventData()->jsonSerialize(); + } } diff --git a/lib/Event/CardDeletedEvent.php b/lib/Event/CardDeletedEvent.php index 3c3d78f51..f061d3611 100644 --- a/lib/Event/CardDeletedEvent.php +++ b/lib/Event/CardDeletedEvent.php @@ -10,5 +10,11 @@ namespace OCA\Deck\Event; -class CardDeletedEvent extends ACardEvent { +use OCP\EventDispatcher\IWebhookCompatibleEvent; + +class CardDeletedEvent extends ACardEvent implements IWebhookCompatibleEvent { + + public function getWebhookSerializable(): array { + return $this->getCard()->toEventData()->jsonSerialize(); + } } diff --git a/lib/Event/CardUpdatedEvent.php b/lib/Event/CardUpdatedEvent.php index ce3aa91b3..08bad496b 100644 --- a/lib/Event/CardUpdatedEvent.php +++ b/lib/Event/CardUpdatedEvent.php @@ -11,8 +11,9 @@ namespace OCA\Deck\Event; use OCA\Deck\Db\Card; +use OCP\EventDispatcher\IWebhookCompatibleEvent; -class CardUpdatedEvent extends ACardEvent { +class CardUpdatedEvent extends ACardEvent implements IWebhookCompatibleEvent { private $cardBefore; public function __construct(Card $card, ?Card $before = null) { @@ -23,4 +24,11 @@ public function __construct(Card $card, ?Card $before = null) { public function getCardBefore() { return $this->cardBefore; } + + public function getWebhookSerializable(): array { + return [ + 'before' => $this->getCardBefore()->toEventData()->jsonSerialize(), + 'after' => $this->getCard()->toEventData()->jsonSerialize() + ]; + } } diff --git a/lib/Model/Public/CardEventData.php b/lib/Model/Public/CardEventData.php new file mode 100644 index 000000000..b883a46ab --- /dev/null +++ b/lib/Model/Public/CardEventData.php @@ -0,0 +1,124 @@ + $labels + * @param string[] $assignedUsers + * @param int $order + * @param bool $archived + * @param int $commentsUnread + * @param int $commentsCount + * @param ?string $owner + * @param ?string $lastEditor + * @param ?\DateTime $duedate + * @param ?\DateTime $doneAt + * @param ?\DateTime $deletedAt + * + * @since 2.0.0 + */ + public function __construct( + /** @readonly */ + public string $title, + /** @readonly */ + public string $description, + /** @readonly */ + public int $boardId, + /** @readonly */ + public int $stackId, + /** @readonly */ + public \DateTime $lastModified, + /** @readonly */ + public \DateTime $createdAt, + /** @readonly */ + public array $labels = [], + /** @readonly */ + public array $assignedUsers = [], + /** @readonly */ + public int $order = 0, + /** @readonly */ + public bool $archived = false, + /** @readonly */ + public int $commentsUnread = 0, + /** @readonly */ + public int $commentsCount = 0, + /** @readonly */ + public ?string $owner = null, + /** @readonly */ + public ?string $lastEditor, + /** @readonly */ + public ?\DateTime $duedate = null, + /** @readonly */ + public ?\DateTime $doneAt = null, + /** @readonly */ + public ?\DateTime $deletedAt = null, + ) { + } + + + /** + * Serialize the object to a JSON-compatible array. + * + * @return array{ + * title: string, + * description: string, + * boardId: int, + * stackId: int, + * lastModified: string, + * createdAt: string, + * labels: array, + * assignedUsers: string[], + * order: int, + * archived: bool, + * commentsUnread: int, + * commentsCount: int, + * owner: ?string, + * lastEditor: ?string, + * duedate: ?string, + * doneAt: ?string, + * deletedAt: ?string, + * } + */ + public function jsonSerialize(): array { + return [ + 'title' => $this->title, + 'description' => $this->description, + 'boardId' => $this->boardId, + 'stackId' => $this->stackId, + 'lastModified' => $this->lastModified->format(DATE_ATOM), + 'createdAt' => $this->createdAt->format(DATE_ATOM), + 'labels' => $this->labels, + 'assignedUsers' => $this->assignedUsers, + 'order' => $this->order, + 'archived' => $this->archived, + 'commentsUnread' => $this->commentsUnread, + 'commentsCount' => $this->commentsCount, + 'owner' => $this->owner, + 'lastEditor' => $this->lastEditor, + 'duedate' => $this->duedate?->format(DATE_ATOM), + 'doneAt' => $this->doneAt?->format(DATE_ATOM), + 'deletedAt' => $this->deletedAt?->format(DATE_ATOM), + ]; + } +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 9f1576a2e..283dfd1a3 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -408,11 +408,12 @@ public function rename($id, $title) { if ($card->getArchived()) { throw new StatusException('Operation not allowed. This card is archived.'); } + $cardBefore = clone $card; $card->setTitle($title); $this->changeHelper->cardChanged($card->getId(), false); $update = $this->cardMapper->update($card); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); return $update; } @@ -449,6 +450,8 @@ public function reorder($id, $stackId, $order) { $changes->setAfter($card); $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_CARD, $changes, ActivityManager::SUBJECT_CARD_UPDATE); + $cardBefore = clone $card; + $cards = $this->cardMapper->findAll($stackId); $result = []; $i = 0; @@ -472,7 +475,7 @@ public function reorder($id, $stackId, $order) { $result[$card->getOrder()] = $card; } $this->changeHelper->cardChanged($id, false); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); return array_values($result); } @@ -495,13 +498,16 @@ public function archive($id) { throw new StatusException('Operation not allowed. This board is archived.'); } $card = $this->cardMapper->find($id); + + $cardBefore = clone $card; + $card->setArchived(true); $newCard = $this->cardMapper->update($card); $this->notificationHelper->markDuedateAsRead($card); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_ARCHIVE); $this->changeHelper->cardChanged($id, false); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); return $newCard; } @@ -524,12 +530,15 @@ public function unarchive($id) { throw new StatusException('Operation not allowed. This board is archived.'); } $card = $this->cardMapper->find($id); + + $cardBefore = clone $card; + $card->setArchived(false); $newCard = $this->cardMapper->update($card); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_UNARCHIVE); $this->changeHelper->cardChanged($id, false); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); return $newCard; } @@ -549,13 +558,16 @@ public function done(int $id): Card { throw new StatusException('Operation not allowed. This board is archived.'); } $card = $this->cardMapper->find($id); + + $cardBefore = clone $card; + $card->setDone(new \DateTime()); $newCard = $this->cardMapper->update($card); $this->notificationHelper->markDuedateAsRead($card); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_DONE); $this->changeHelper->cardChanged($id, false); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); return $newCard; } @@ -575,12 +587,15 @@ public function undone(int $id): Card { throw new StatusException('Operation not allowed. This board is archived.'); } $card = $this->cardMapper->find($id); + + $cardBefore = clone $card; + $card->setDone(null); $newCard = $this->cardMapper->update($card); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_UNDONE); $this->changeHelper->cardChanged($id, false); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); return $newCard; } @@ -606,12 +621,15 @@ public function assignLabel($cardId, $labelId) { if ($card->getArchived()) { throw new StatusException('Operation not allowed. This card is archived.'); } + + $cardBefore = clone $card; + $label = $this->labelMapper->find($labelId); $this->cardMapper->assignLabel($cardId, $labelId); $this->changeHelper->cardChanged($cardId); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_ASSIGN, ['label' => $label]); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); } /** @@ -635,12 +653,15 @@ public function removeLabel($cardId, $labelId) { if ($card->getArchived()) { throw new StatusException('Operation not allowed. This card is archived.'); } + + $cardBefore = clone $card; + $label = $this->labelMapper->find($labelId); $this->cardMapper->removeLabel($cardId, $labelId); $this->changeHelper->cardChanged($cardId); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_UNASSING, ['label' => $label]); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $cardBefore)); } public function getCardUrl($cardId) { diff --git a/mkdocs.yml b/mkdocs.yml index 046067cd8..d0384977d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ pages: - API documentation: - REST API: API.md - Nextcloud API: API-Nextcloud.md + - Events: events.md - Developer documentation: - Data structure: structure.md - Import documentation: diff --git a/tests/unit/Db/CardTest.php b/tests/unit/Db/CardTest.php index 8c761130a..b294d82e9 100644 --- a/tests/unit/Db/CardTest.php +++ b/tests/unit/Db/CardTest.php @@ -47,6 +47,14 @@ private function createCard() { return $card; } + private function createLabel() { + $label = new Label(); + $label->setId(1); + $label->setTitle('Label 1'); + $label->setColor('000000'); + return $label; + } + public function dataDuedate() { return [ [(new DateTime()), Card::DUEDATE_NOW], @@ -150,4 +158,85 @@ public function testJsonSerializeAsignedUsers() { 'done' => false, ], (new CardDetails($card))->jsonSerialize()); } + + public function testToEventDataSerializationSimple() { + $card = $this->createCard(); + $board = new Board(); + $board->setId(1); + $card->setRelatedBoard($board); + + $lastmodified = new DateTime('2024-01-01 00:00:00'); + $card->setLastModified($lastmodified->getTimestamp()); + $created = new DateTime('2024-01-02 00:00:00'); + $card->setCreatedAt($created->getTimestamp()); + + $this->assertEquals([ + 'title' => 'My Card', + 'description' => 'a long description', + 'boardId' => 1, + 'stackId' => 1, + 'lastModified' => $lastmodified->format(DATE_ATOM), + 'createdAt' => $created->format(DATE_ATOM), + 'labels' => [], + 'assignedUsers' => [], + 'order' => 12, + 'archived' => false, + 'commentsUnread' => 0, + 'commentsCount' => 0, + 'owner' => 'admin', + 'lastEditor' => null, + 'duedate' => null, + 'doneAt' => null, + 'deletedAt' => null, + ], $card->toEventData()->jsonSerialize()); + } + + public function testToEventDataSerializationFull() { + $card = $this->createCard(); + + $board = new Board(); + $board->setId(1); + $card->setRelatedBoard($board); + + $card->setAssignedUsers([ 'user1' ]); + $card->setLabels([$this->createLabel()]); + $card->setLastEditor('someuser'); + $card->setArchived(true); + + $lastModified = new DateTime('2024-01-01 00:00:00'); + $card->setLastModified($lastModified->getTimestamp()); + $createdAt = new DateTime('2024-01-02 00:00:00'); + $card->setCreatedAt($createdAt->getTimestamp()); + $doneAt = new DateTime('2024-01-03 00:00:00'); + $card->setDone($doneAt); + $duedate = new DateTime('2024-01-05 00:00:00'); + $card->setDuedate($duedate); + $deletedAt = new DateTime('2024-01-05 00:00:00'); + $card->setDeletedAt($deletedAt->getTimestamp()); + + $this->assertEquals([ + 'title' => 'My Card', + 'description' => 'a long description', + 'boardId' => 1, + 'stackId' => 1, + 'lastModified' => $lastModified->format(DATE_ATOM), + 'createdAt' => $createdAt->format(DATE_ATOM), + 'labels' => [ + [ + 'id' => 1, + 'title' => 'Label 1' + ] + ], + 'assignedUsers' => ['user1'], + 'order' => 12, + 'archived' => true, + 'commentsUnread' => 0, + 'commentsCount' => 0, + 'doneAt' => $doneAt->format(DATE_ATOM), + 'owner' => 'admin', + 'lastEditor' => 'someuser', + 'duedate' => $duedate->format(DATE_ATOM), + 'deletedAt' => $deletedAt->format(DATE_ATOM), + ], $card->toEventData()->jsonSerialize()); + } } diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index e5600b3ae..34246d7f1 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -36,6 +36,9 @@ use OCA\Deck\Db\LabelMapper; use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; +use OCA\Deck\Event\CardCreatedEvent; +use OCA\Deck\Event\CardDeletedEvent; +use OCA\Deck\Event\CardUpdatedEvent; use OCA\Deck\Model\CardDetails; use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\StatusException; @@ -216,6 +219,15 @@ public function testCreate() { $this->cardMapper->expects($this->once()) ->method('insert') ->willReturn($card); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) use ($card) { + $this->assertInstanceOf(CardCreatedEvent::class, $event); + $this->assertEquals($card->getTitle(), $event->getCard()->getTitle()); + return true; + })); + $b = $this->cardService->create('Card title', 123, 'text', 999, 'admin'); $this->assertEquals($b->getTitle(), 'Card title'); @@ -288,7 +300,15 @@ public function testDelete() { $this->cardMapper->expects($this->once()) ->method('update') ->willReturn($cardToBeDeleted); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return $event instanceof CardDeletedEvent; + })); + $this->cardService->delete(123); + $this->assertTrue($cardToBeDeleted->getDeletedAt() <= time(), 'deletedAt is in the past'); } @@ -300,7 +320,15 @@ public function testUpdate() { $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { return $c; }); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return $event instanceof CardUpdatedEvent; + })); + $actual = $this->cardService->update(123, 'newtitle', 234, 'text', 'admin', 'foo', 999, '2017-01-01 00:00:00', null); + $this->assertEquals('newtitle', $actual->getTitle()); $this->assertEquals(234, $actual->getStackId()); $this->assertEquals('text', $actual->getType()); @@ -327,7 +355,17 @@ public function testRename() { $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { return $c; }); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return ($event instanceof CardUpdatedEvent) + && ($event->getCard()->getTitle() == '') + && ($event->getCardBefore()->getTitle() == 'title'); + })); + $actual = $this->cardService->rename(123, 'newtitle'); + $this->assertEquals('newtitle', $actual->getTitle()); } @@ -356,7 +394,15 @@ public function testReorder($cardId, $newPosition, $order) { $card = new Card(); $card->setStackId(123); $this->cardMapper->expects($this->once())->method('find')->willReturn($card); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return $event instanceof CardUpdatedEvent; + })); + $result = $this->cardService->reorder($cardId, 123, $newPosition); + foreach ($result as $card) { $actual[$card->getOrder()] = $card->getId(); } @@ -393,6 +439,12 @@ public function testArchive() { $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { return $c; }); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return $event instanceof CardUpdatedEvent; + })); $this->assertTrue($this->cardService->archive(123)->getArchived()); } public function testUnarchive() { @@ -403,6 +455,12 @@ public function testUnarchive() { $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { return $c; }); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return $event instanceof CardUpdatedEvent; + })); $this->assertFalse($this->cardService->unarchive(123)->getArchived()); } @@ -411,6 +469,12 @@ public function testAssignLabel() { $card->setArchived(false); $this->cardMapper->expects($this->once())->method('find')->willReturn($card); $this->cardMapper->expects($this->once())->method('assignLabel'); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return $event instanceof CardUpdatedEvent; + })); $this->cardService->assignLabel(123, 999); } @@ -428,6 +492,12 @@ public function testRemoveLabel() { $card->setArchived(false); $this->cardMapper->expects($this->once())->method('find')->willReturn($card); $this->cardMapper->expects($this->once())->method('removeLabel'); + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function ($event) { + return $event instanceof CardUpdatedEvent; + })); $this->cardService->removeLabel(123, 999); }