diff --git a/appinfo/routes.php b/appinfo/routes.php index 45d6555f3..e04347f46 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -41,6 +41,12 @@ ['name' => 'stack#deleted', 'url' => '/{boardId}/stacks/deleted', 'verb' => 'GET'], ['name' => 'stack#archived', 'url' => '/stacks/{boardId}/archived', 'verb' => 'GET'], + // stack automations + ['name' => 'stack_automation#index', 'url' => '/stacks/{stackId}/automations', 'verb' => 'GET'], + ['name' => 'stack_automation#create', 'url' => '/stacks/{stackId}/automations', 'verb' => 'POST'], + ['name' => 'stack_automation#update', 'url' => '/automations/{id}', 'verb' => 'PUT'], + ['name' => 'stack_automation#delete', 'url' => '/automations/{id}', 'verb' => 'DELETE'], + // cards ['name' => 'card#read', 'url' => '/cards/{cardId}', 'verb' => 'GET'], ['name' => 'card#create', 'url' => '/cards', 'verb' => 'POST'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 71c732522..45f8fac25 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -29,6 +29,7 @@ use OCA\Deck\Event\SessionCreatedEvent; use OCA\Deck\Listeners\AclCreatedRemovedListener; use OCA\Deck\Listeners\BeforeTemplateRenderedListener; +use OCA\Deck\Listeners\CardAutomationListener; use OCA\Deck\Listeners\CommentEventListener; use OCA\Deck\Listeners\FullTextSearchEventListener; use OCA\Deck\Listeners\LiveUpdateListener; @@ -143,6 +144,12 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(CardCreatedEvent::class, FullTextSearchEventListener::class); $context->registerEventListener(CardUpdatedEvent::class, FullTextSearchEventListener::class); $context->registerEventListener(CardDeletedEvent::class, FullTextSearchEventListener::class); + + // Event listening for stack automations + $context->registerEventListener(CardCreatedEvent::class, CardAutomationListener::class); + $context->registerEventListener(CardUpdatedEvent::class, CardAutomationListener::class); + $context->registerEventListener(CardDeletedEvent::class, CardAutomationListener::class); + $context->registerEventListener(AclCreatedEvent::class, FullTextSearchEventListener::class); $context->registerEventListener(AclUpdatedEvent::class, FullTextSearchEventListener::class); $context->registerEventListener(AclDeletedEvent::class, FullTextSearchEventListener::class); diff --git a/lib/Automation/ActionFactory.php b/lib/Automation/ActionFactory.php new file mode 100644 index 000000000..e21200b82 --- /dev/null +++ b/lib/Automation/ActionFactory.php @@ -0,0 +1,60 @@ + new AddLabelAction($this->cardMapper, $this->labelMapper, $this->logger), + self::ACTION_REMOVE_LABEL => new RemoveLabelAction($this->cardMapper, $this->labelMapper, $this->logger), + self::ACTION_SET_DONE => new SetDoneAction($this->cardMapper, $this->logger), + self::ACTION_REMOVE_DONE => new RemoveDoneAction($this->cardMapper, $this->logger), + self::ACTION_WEBHOOK => new WebhookAction($this->clientService, $this->logger), + self::ACTION_ARCHIVE => new ArchiveAction($this->cardMapper, $this->logger), + default => null, + }; + } + + public function getSupportedActions(): array { + return [ + self::ACTION_ADD_LABEL, + self::ACTION_REMOVE_LABEL, + self::ACTION_SET_DONE, + self::ACTION_REMOVE_DONE, + self::ACTION_WEBHOOK, + self::ACTION_ARCHIVE, + ]; + } +} diff --git a/lib/Automation/ActionInterface.php b/lib/Automation/ActionInterface.php new file mode 100644 index 000000000..07664cfca --- /dev/null +++ b/lib/Automation/ActionInterface.php @@ -0,0 +1,47 @@ +logger->warning('AddLabelAction: Missing or invalid labelIds in configuration'); + return; + } + + try { + // Get currently assigned labels + $assignedLabels = $this->labelMapper->findAssignedLabelsForCard($card->getId()); + $assignedLabelIds = array_map(fn($label) => $label->getId(), $assignedLabels); + + foreach ($labelIds as $labelId) { + // Only add if not already assigned + if (!in_array((int)$labelId, $assignedLabelIds, true)) { + $this->cardMapper->assignLabel($card->getId(), (int)$labelId); + } + } + } catch (\Exception $e) { + $this->logger->error('AddLabelAction failed: ' . $e->getMessage(), ['exception' => $e]); + throw $e; + } + } + + public function validateConfig(array $config): bool { + return isset($config['labelIds']) + && is_array($config['labelIds']) + && !empty($config['labelIds']) + && array_reduce($config['labelIds'], fn($valid, $id) => $valid && is_numeric($id), true); + } + + public function isApplicableForEvent(AutomationEvent $event): bool { + return $event->getEventName() !== AutomationEvent::EVENT_DELETE; + } + + public function getDescription(array $config): string { + $labelIds = $config['labelIds'] ?? []; + $count = count($labelIds); + return $count > 0 ? "Add {$count} label(s)" : "Add labels"; + } +} diff --git a/lib/Automation/Actions/ArchiveAction.php b/lib/Automation/Actions/ArchiveAction.php new file mode 100644 index 000000000..6a3f7f108 --- /dev/null +++ b/lib/Automation/Actions/ArchiveAction.php @@ -0,0 +1,45 @@ +setArchived(true); + $this->cardMapper->update($card); + } catch (\Exception $e) { + $this->logger->error('ArchiveAction failed: ' . $e->getMessage(), ['exception' => $e]); + throw $e; + } + } + + public function validateConfig(array $config): bool { + // No configuration needed + return true; + } + + public function isApplicableForEvent(AutomationEvent $event): bool { + return $event->getEventName() !== AutomationEvent::EVENT_DELETE; + } + + public function getDescription(array $config): string { + return "Archive card"; + } +} diff --git a/lib/Automation/Actions/RemoveDoneAction.php b/lib/Automation/Actions/RemoveDoneAction.php new file mode 100644 index 000000000..4b22a212b --- /dev/null +++ b/lib/Automation/Actions/RemoveDoneAction.php @@ -0,0 +1,45 @@ +setDone(null); + $this->cardMapper->update($card); + } catch (\Exception $e) { + $this->logger->error('RemoveDoneAction failed: ' . $e->getMessage(), ['exception' => $e]); + throw $e; + } + } + + public function validateConfig(array $config): bool { + // No configuration needed + return true; + } + + public function isApplicableForEvent(AutomationEvent $event): bool { + return $event->getEventName() !== AutomationEvent::EVENT_DELETE; + } + + public function getDescription(array $config): string { + return "Unmark card as done"; + } +} diff --git a/lib/Automation/Actions/RemoveLabelAction.php b/lib/Automation/Actions/RemoveLabelAction.php new file mode 100644 index 000000000..6bdb75796 --- /dev/null +++ b/lib/Automation/Actions/RemoveLabelAction.php @@ -0,0 +1,66 @@ +logger->warning('RemoveLabelAction: Missing or invalid labelIds in configuration'); + return; + } + + try { + // Get currently assigned labels + $assignedLabels = $this->labelMapper->findAssignedLabelsForCard($card->getId()); + $assignedLabelIds = array_map(fn($label) => $label->getId(), $assignedLabels); + + foreach ($labelIds as $labelId) { + // Only remove if currently assigned + if (in_array((int)$labelId, $assignedLabelIds, true)) { + $this->cardMapper->removeLabel($card->getId(), (int)$labelId); + } + } + } catch (\Exception $e) { + $this->logger->error('RemoveLabelAction failed: ' . $e->getMessage(), ['exception' => $e]); + throw $e; + } + } + + public function validateConfig(array $config): bool { + return isset($config['labelIds']) + && is_array($config['labelIds']) + && !empty($config['labelIds']) + && array_reduce($config['labelIds'], fn($valid, $id) => $valid && is_numeric($id), true); + } + + public function isApplicableForEvent(AutomationEvent $event): bool { + return $event->getEventName() !== AutomationEvent::EVENT_DELETE; + } + + public function getDescription(array $config): string { + $labelIds = $config['labelIds'] ?? []; + $count = count($labelIds); + return $count > 0 ? "Remove {$count} label(s)" : "Remove labels"; + } +} diff --git a/lib/Automation/Actions/SetDoneAction.php b/lib/Automation/Actions/SetDoneAction.php new file mode 100644 index 000000000..6c447326b --- /dev/null +++ b/lib/Automation/Actions/SetDoneAction.php @@ -0,0 +1,45 @@ +setDone(time()); + $this->cardMapper->update($card); + } catch (\Exception $e) { + $this->logger->error('SetDoneAction failed: ' . $e->getMessage(), ['exception' => $e]); + throw $e; + } + } + + public function validateConfig(array $config): bool { + // No configuration needed + return true; + } + + public function isApplicableForEvent(AutomationEvent $event): bool { + return $event->getEventName() !== AutomationEvent::EVENT_DELETE; + } + + public function getDescription(array $config): string { + return "Mark card as done"; + } +} diff --git a/lib/Automation/Actions/WebhookAction.php b/lib/Automation/Actions/WebhookAction.php new file mode 100644 index 000000000..2883e7ed5 --- /dev/null +++ b/lib/Automation/Actions/WebhookAction.php @@ -0,0 +1,86 @@ +logger->warning('WebhookAction: Invalid or missing URL in configuration'); + return; + } + + try { + $client = $this->clientService->newClient(); + + $options = [ + 'headers' => $headers, + 'timeout' => 10, + ]; + + if ($method === 'POST') { + $payload = [ + 'cardId' => $card->getId(), + 'cardTitle' => $card->getTitle(), + 'stackId' => $card->getStackId(), + 'event' => $event->getEventName(), + 'fromStackId' => $event->getFromStackId(), + 'toStackId' => $event->getToStackId(), + ]; + + $options['json'] = $payload; + $client->post($url, $options); + } elseif ($method === 'GET') { + $client->get($url, $options); + } else { + $this->logger->warning('WebhookAction: Unsupported HTTP method: ' . $method); + } + } catch (\Exception $e) { + $this->logger->error('WebhookAction failed: ' . $e->getMessage(), ['exception' => $e]); + // Don't throw - webhook failures shouldn't block card operations + } + } + + public function validateConfig(array $config): bool { + if (!isset($config['url'])) { + return false; + } + if (!filter_var($config['url'], FILTER_VALIDATE_URL)) { + return false; + } + if (isset($config['method']) && !in_array($config['method'], ['GET', 'POST'])) { + return false; + } + return true; + } + + public function isApplicableForEvent(AutomationEvent $event): bool { + return true; + } + + public function getDescription(array $config): string { + $url = $config['url'] ?? 'unknown'; + $method = $config['method'] ?? 'POST'; + return "Call webhook {$method} {$url}"; + } +} diff --git a/lib/Automation/AutomationEvent.php b/lib/Automation/AutomationEvent.php new file mode 100644 index 000000000..4fece0b49 --- /dev/null +++ b/lib/Automation/AutomationEvent.php @@ -0,0 +1,45 @@ +eventName = $eventName; +$this->fromStackId = $fromStackId; +$this->toStackId = $toStackId; +} + +public function getEventName(): string { +return $this->eventName; +} + +public function getFromStackId(): ?int { +return $this->fromStackId; +} + +public function getToStackId(): ?int { +return $this->toStackId; +} +} diff --git a/lib/Controller/StackAutomationController.php b/lib/Controller/StackAutomationController.php new file mode 100644 index 000000000..802331f40 --- /dev/null +++ b/lib/Controller/StackAutomationController.php @@ -0,0 +1,78 @@ +automationService->getAutomations($stackId); + } + + /** + * Create a new automation + */ + #[NoAdminRequired] + public function create(int $stackId, string $event, string $actionType, array $config, int $order = 0): JSONResponse { + try { + $automation = $this->automationService->createAutomation($stackId, $event, $actionType, $config, $order); + return new JSONResponse($automation, 201); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], 400); + } catch (\Exception $e) { + return new JSONResponse(['error' => 'Failed to create automation'], 500); + } + } + + /** + * Update an automation + */ + #[NoAdminRequired] + public function update(int $id, string $event, string $actionType, array $config, int $order): JSONResponse { + try { + $automation = $this->automationService->updateAutomation($id, $event, $actionType, $config, $order); + return new JSONResponse($automation); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], 400); + } catch (\Exception $e) { + return new JSONResponse(['error' => 'Failed to update automation'], 500); + } + } + + /** + * Delete an automation + */ + #[NoAdminRequired] + public function delete(int $id): JSONResponse { + try { + $this->automationService->deleteAutomation($id); + return new JSONResponse(['success' => true]); + } catch (\Exception $e) { + return new JSONResponse(['error' => 'Failed to delete automation'], 500); + } + } +} diff --git a/lib/Db/StackAutomation.php b/lib/Db/StackAutomation.php new file mode 100644 index 000000000..1b0dd3ae3 --- /dev/null +++ b/lib/Db/StackAutomation.php @@ -0,0 +1,61 @@ +addType('stackId', 'integer'); + $this->addType('order', 'integer'); + $this->addType('createdAt', 'integer'); + $this->addType('updatedAt', 'integer'); + } + + public function jsonSerialize(): array { + $data = parent::jsonSerialize(); + // Decode JSON config for easier frontend consumption + if (isset($data['actionConfig'])) { + $data['actionConfig'] = json_decode($data['actionConfig'], true) ?? []; + } + return $data; + } + + public function setActionConfigArray(array $config): void { + $this->setActionConfig(json_encode($config)); + } + + public function getActionConfigArray(): array { + $decoded = json_decode($this->actionConfig ?? '{}', true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/lib/Db/StackAutomationMapper.php b/lib/Db/StackAutomationMapper.php new file mode 100644 index 000000000..72c92a360 --- /dev/null +++ b/lib/Db/StackAutomationMapper.php @@ -0,0 +1,76 @@ + + */ +class StackAutomationMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'deck_stack_automations', StackAutomation::class); + } + + /** + * @param int $id + * @return StackAutomation + */ + public function find(int $id): StackAutomation { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * @param int $stackId + * @return StackAutomation[] + */ + public function findByStackId(int $stackId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('stack_id', $qb->createNamedParameter($stackId, IQueryBuilder::PARAM_INT))) + ->orderBy('order', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * @param int $stackId + * @param string $event + * @return StackAutomation[] + */ + public function findByStackIdAndEvent(int $stackId, string $event): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('stack_id', $qb->createNamedParameter($stackId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('event', $qb->createNamedParameter($event, IQueryBuilder::PARAM_STR))) + ->orderBy('order', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * @param int $stackId + */ + public function deleteByStackId(int $stackId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('stack_id', $qb->createNamedParameter($stackId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } +} diff --git a/lib/Listeners/CardAutomationListener.php b/lib/Listeners/CardAutomationListener.php new file mode 100644 index 000000000..e0cbb37b3 --- /dev/null +++ b/lib/Listeners/CardAutomationListener.php @@ -0,0 +1,79 @@ + + */ +class CardAutomationListener implements IEventListener { + public function __construct( + private StackAutomationService $automationService, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof CardCreatedEvent) { + $this->handleCardCreated($event); + } elseif ($event instanceof CardUpdatedEvent) { + $this->handleCardUpdated($event); + } elseif ($event instanceof CardDeletedEvent) { + $this->handleCardDeleted($event); + } + } + + private function handleCardCreated(CardCreatedEvent $event): void { + try { + $card = $event->getCard(); + $this->automationService->executeAutomationsForEvent($event, 'create', $card->getStackId()); + } catch (\Exception $e) { + $this->logger->error('Failed to execute automations on card create', ['exception' => $e]); + } + } + + private function handleCardUpdated(CardUpdatedEvent $event): void { + try { + $card = $event->getCard(); + $cardBefore = $event->getCardBefore(); + + if ($cardBefore === null) { + return; + } + + $oldStackId = $cardBefore->getStackId(); + $newStackId = $card->getStackId(); + + // Check if card moved between stacks + if ($oldStackId !== $newStackId) { + // Execute EXIT on old stack + $this->automationService->executeAutomationsForEvent($event, 'exit', $oldStackId); + // Execute ENTER on new stack + $this->automationService->executeAutomationsForEvent($event, 'enter', $newStackId); + } + } catch (\Exception $e) { + $this->logger->error('Failed to execute automations on card update', ['exception' => $e]); + } + } + + private function handleCardDeleted(CardDeletedEvent $event): void { + try { + $card = $event->getCard(); + $this->automationService->executeAutomationsForEvent($event, 'delete', $card->getStackId()); + } catch (\Exception $e) { + $this->logger->error('Failed to execute automations on card delete', ['exception' => $e]); + } + } +} diff --git a/lib/Migration/Version11100Date20260123000000.php b/lib/Migration/Version11100Date20260123000000.php new file mode 100644 index 000000000..24c0f9f5b --- /dev/null +++ b/lib/Migration/Version11100Date20260123000000.php @@ -0,0 +1,69 @@ +hasTable('deck_stack_automations')) { + $table = $schema->createTable('deck_stack_automations'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('stack_id', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('event', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('action_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('action_config', Types::TEXT, [ + 'notnull' => true, + 'default' => '{}', + ]); + $table->addColumn('order', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('updated_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['stack_id'], 'deck_stack_auto_stack_idx'); + $table->addIndex(['event'], 'deck_stack_auto_event_idx'); + + return $schema; + } + + return null; + } +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index f5995874f..d351a39f0 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -215,6 +215,7 @@ public function delete(int $id): Card { throw new StatusException('Operation not allowed. This board is archived.'); } $card = $this->cardMapper->find($id); + $oldStackId = $card->getStackId(); $card->setDeletedAt(time()); $this->cardMapper->update($card); @@ -430,11 +431,17 @@ public function reorder(int $id, int $stackId, int $order): array { throw new StatusException('Operation not allowed. This card is archived.'); } $changes = new ChangeSet($card); + $oldStackId = $card->getStackId(); $card->setStackId($stackId); $this->cardMapper->update($card); $changes->setAfter($card); $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_CARD, $changes, ActivityManager::SUBJECT_CARD_UPDATE); + // Re-fetch the card after update to include any automation changes + if ($oldStackId !== $stackId) { + $card = $this->cardMapper->find($id); + } + $cardsToReorder = $this->cardMapper->findAll($stackId); $result = []; $i = 0; @@ -460,7 +467,10 @@ public function reorder(int $id, int $stackId, int $order): array { $this->changeHelper->cardChanged($id, false); $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore())); - return array_values($result); + // Enrich cards with labels and other data before returning + $enrichedResult = $this->enrichCards(array_values($result)); + + return $enrichedResult; } /** diff --git a/lib/Service/StackAutomationService.php b/lib/Service/StackAutomationService.php new file mode 100644 index 000000000..7e84c248b --- /dev/null +++ b/lib/Service/StackAutomationService.php @@ -0,0 +1,185 @@ +automationMapper->findByStackId($stackId); + } + + /** + * Create a new automation rule + */ + public function createAutomation(int $stackId, string $event, string $actionType, array $config, int $order = 0): StackAutomation { + // Validate action type + if (!in_array($actionType, $this->actionFactory->getSupportedActions())) { + throw new \InvalidArgumentException('Unsupported action type: ' . $actionType); + } + + // Validate configuration + $action = $this->actionFactory->createAction($actionType); + if ($action && !$action->validateConfig($config)) { + throw new \InvalidArgumentException('Invalid configuration for action: ' . $actionType); + } + + // Validate event + if (!in_array($event, [AutomationEvent::EVENT_CREATE, AutomationEvent::EVENT_DELETE, AutomationEvent::EVENT_ENTER, AutomationEvent::EVENT_EXIT])) { + throw new \InvalidArgumentException('Invalid event: ' . $event); + } + + $automation = new StackAutomation(); + $automation->setStackId($stackId); + $automation->setEvent($event); + $automation->setActionType($actionType); + $automation->setActionConfigArray($config); + $automation->setOrder($order); + $automation->setCreatedAt(time()); + $automation->setUpdatedAt(time()); + + return $this->automationMapper->insert($automation); + } + + /** + * Update an existing automation rule + */ + public function updateAutomation(int $id, string $event, string $actionType, array $config, int $order): StackAutomation { + $automation = $this->automationMapper->find($id); + + // Validate action type + if (!in_array($actionType, $this->actionFactory->getSupportedActions())) { + throw new \InvalidArgumentException('Unsupported action type: ' . $actionType); + } + + // Validate configuration + $action = $this->actionFactory->createAction($actionType); + if ($action && !$action->validateConfig($config)) { + throw new \InvalidArgumentException('Invalid configuration for action: ' . $actionType); + } + + // Validate event + if (!in_array($event, [AutomationEvent::EVENT_CREATE, AutomationEvent::EVENT_DELETE, AutomationEvent::EVENT_ENTER, AutomationEvent::EVENT_EXIT])) { + throw new \InvalidArgumentException('Invalid event: ' . $event); + } + + $automation->setEvent($event); + $automation->setActionType($actionType); + $automation->setActionConfigArray($config); + $automation->setOrder($order); + $automation->setUpdatedAt(time()); + + return $this->automationMapper->update($automation); + } + + /** + * Delete an automation rule + */ + public function deleteAutomation(int $id): void { + $automation = $this->automationMapper->find($id); + $this->automationMapper->delete($automation); + } + + /** + * Execute automations for a specific event using Nextcloud event objects + */ + public function executeAutomationsForEvent(\OCP\EventDispatcher\Event $event, string $eventName, int $stackId): void { + $card = null; + + if ($event instanceof \OCA\Deck\Event\CardCreatedEvent || + $event instanceof \OCA\Deck\Event\CardUpdatedEvent || + $event instanceof \OCA\Deck\Event\CardDeletedEvent) { + $card = $event->getCard(); + } + + if ($card === null) { + $this->logger->warning('Card not found in event for automation execution'); + return; + } + + $this->executeAutomations($stackId, $eventName, $card); + } + + /** + * Execute automations for a given stack and event + */ + public function executeAutomations(int $stackId, string $eventName, Card $card, ?int $fromStackId = null, ?int $toStackId = null): void { + $this->logger->info('Executing automations for stack', [ + 'stackId' => $stackId, + 'eventName' => $eventName, + 'cardId' => $card->getId(), + ]); + + $automations = $this->automationMapper->findByStackIdAndEvent($stackId, $eventName); + + $this->logger->info('Found automations', [ + 'count' => count($automations), + 'automations' => array_map(fn($a) => ['id' => $a->getId(), 'event' => $a->getEvent(), 'actionType' => $a->getActionType()], $automations), + ]); + + if (empty($automations)) { + return; + } + + $event = new AutomationEvent($eventName, $fromStackId, $toStackId); + + foreach ($automations as $automation) { + $action = $this->actionFactory->createAction($automation->getActionType()); + + if ($action === null) { + $this->logger->warning('Unknown action type: ' . $automation->getActionType()); + continue; + } + + // Check if action is applicable for this event + if (!$action->isApplicableForEvent($event)) { + $this->logger->debug('Action not applicable for event', [ + 'actionType' => $automation->getActionType(), + 'eventName' => $eventName, + ]); + continue; + } + + try { + $config = $automation->getActionConfigArray(); + $action->execute($card, $event, $config); + $this->logger->info('Automation executed successfully', [ + 'automationId' => $automation->getId(), + 'actionType' => $automation->getActionType(), + 'cardId' => $card->getId(), + ]); + } catch (\Exception $e) { + $this->logger->error('Automation execution failed', [ + 'automationId' => $automation->getId(), + 'actionType' => $automation->getActionType(), + 'cardId' => $card->getId(), + 'error' => $e->getMessage(), + ]); + // Continue with other automations even if one fails + } + } + } +} diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 3d0753a08..af1aec6a6 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -40,19 +40,25 @@ - + + + {{ t('deck', 'Automation rules') }} + + {{ t('deck', 'Archive all cards') }} - + {{ t('deck', 'Unarchive all cards') }} - + {{ t('deck', 'Delete list') }} @@ -130,6 +136,10 @@ + + @@ -139,10 +149,12 @@ import { mapGetters, mapState } from 'vuex' import { Container, Draggable } from 'vue-smooth-dnd' import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue' import CardPlusOutline from 'vue-material-design-icons/CardPlusOutline.vue' +import CogIcon from 'vue-material-design-icons/Cog.vue' import { NcActions, NcActionButton, NcModal } from '@nextcloud/vue' import { showError, showUndo } from '@nextcloud/dialogs' import CardItem from '../cards/CardItem.vue' +import StackAutomationSettings from './StackAutomationSettings.vue' import '@nextcloud/dialogs/style.css' @@ -157,6 +169,8 @@ export default { NcModal, ArchiveIcon, CardPlusOutline, + CogIcon, + StackAutomationSettings, }, directives: { ClickOutside, @@ -181,6 +195,7 @@ export default { stateCardCreating: false, animate: false, modalArchivAllCardsShow: false, + showAutomationSettings: false, stackTransfer: { total: 0, current: null, diff --git a/src/components/board/StackAutomationItem.vue b/src/components/board/StackAutomationItem.vue new file mode 100644 index 000000000..12cd82941 --- /dev/null +++ b/src/components/board/StackAutomationItem.vue @@ -0,0 +1,186 @@ + + + + + + + diff --git a/src/components/board/StackAutomationSettings.vue b/src/components/board/StackAutomationSettings.vue new file mode 100644 index 000000000..bc9ec4c5b --- /dev/null +++ b/src/components/board/StackAutomationSettings.vue @@ -0,0 +1,401 @@ + + + + + + + diff --git a/src/components/card/TagSelector.vue b/src/components/card/TagSelector.vue index 2482c4d27..e16ecf552 100644 --- a/src/components/card/TagSelector.vue +++ b/src/components/card/TagSelector.vue @@ -55,6 +55,10 @@ export default { type: Object, default: null, }, + value: { + type: Array, + default: null, + }, labels: { type: Array, default: () => [], @@ -65,16 +69,25 @@ export default { }, }, computed: { + assignedLabels() { + // Use value prop if provided (v-model), otherwise fall back to card.labels + const labels = this.value !== null ? this.value : (this.card?.labels || []) + return [...labels].sort((a, b) => (a.title < b.title) ? -1 : 1) + }, labelsSorted() { return [...this.labels].sort((a, b) => (a.title < b.title) ? -1 : 1) - .filter(label => this.card.labels.findIndex((l) => l.id === label.id) === -1) - }, - assignedLabels() { - return [...this.card.labels].sort((a, b) => (a.title < b.title) ? -1 : 1) + .filter(label => this.assignedLabels.findIndex((l) => l.id === label.id) === -1) }, }, methods: { onSelect(options) { + // When using v-model (value prop), emit input with all selected labels + if (this.value !== null) { + this.$emit('input', options || []) + return + } + + // Legacy card-based behavior: emit select for single label const addedLabel = options.filter(option => !this.card.labels.includes(option) && option.id && option.color) if (addedLabel.length === 0) { return @@ -82,6 +95,14 @@ export default { this.$emit('select', addedLabel[0]) }, onRemove(removedLabel) { + // When using v-model, emit input with updated array + if (this.value !== null) { + const updated = this.assignedLabels.filter(l => l.id !== removedLabel.id) + this.$emit('input', updated) + return + } + + // Legacy card-based behavior this.$emit('remove', removedLabel) }, async onNewTag(option) { diff --git a/src/services/StackAutomationApi.js b/src/services/StackAutomationApi.js new file mode 100644 index 000000000..1054a9e02 --- /dev/null +++ b/src/services/StackAutomationApi.js @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export class StackAutomationApi { + url(url) { + url = `/apps/deck${url}` + return generateUrl(url) + } + + loadAutomations(stackId) { + return axios.get(this.url(`/stacks/${stackId}/automations`)) + .then( + (response) => { + return Promise.resolve(response.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + + createAutomation(stackId, event, actionType, config, order = 0) { + return axios.post(this.url(`/stacks/${stackId}/automations`), { + event, + actionType, + config, + order, + }) + .then( + (response) => { + return Promise.resolve(response.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + + updateAutomation(id, event, actionType, config, order) { + return axios.put(this.url(`/automations/${id}`), { + event, + actionType, + config, + order, + }) + .then( + (response) => { + return Promise.resolve(response.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + + deleteAutomation(id) { + return axios.delete(this.url(`/automations/${id}`)) + .then( + (response) => { + return Promise.resolve(response.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } +} diff --git a/src/store/card.js b/src/store/card.js index c8cc1d11d..a510305a5 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -216,8 +216,8 @@ export default function cardModuleFactory() { for (const newCard of cards) { const existingIndex = state.cards.findIndex(_card => _card.id === newCard.id) if (existingIndex !== -1) { - Vue.set(state.cards[existingIndex], 'order', newCard.order) - Vue.set(state.cards[existingIndex], 'stackId', newCard.stackId) + // Merge all properties from server response (includes labels from automations) + Vue.set(state.cards, existingIndex, Object.assign({}, state.cards[existingIndex], newCard)) } } }, diff --git a/src/store/main.js b/src/store/main.js index b9620d87f..a7801163b 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -13,6 +13,7 @@ import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { BoardApi } from '../services/BoardApi.js' import actions from './actions.js' import stackModuleFactory from './stack.js' +import stackAutomationModuleFactory from './stackAutomation.js' import cardModuleFactory from './card.js' import comment from './comment.js' import trashbin from './trashbin.js' @@ -34,6 +35,7 @@ export default function storeFactory() { modules: { actions, stack: stackModuleFactory(), + stackAutomation: stackAutomationModuleFactory(), card: cardModuleFactory(), comment, trashbin, diff --git a/src/store/stackAutomation.js b/src/store/stackAutomation.js new file mode 100644 index 000000000..047245ab6 --- /dev/null +++ b/src/store/stackAutomation.js @@ -0,0 +1,92 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import { StackAutomationApi } from '../services/StackAutomationApi.js' + +const apiClient = new StackAutomationApi() + +export default function stackAutomationModuleFactory() { + return { + state: { + automations: {}, + }, + getters: { + automationsByStack: state => (stackId) => { + return state.automations[stackId] || [] + }, + }, + mutations: { + setAutomations(state, { stackId, automations }) { + Vue.set(state.automations, stackId, automations) + }, + addAutomation(state, { stackId, automation }) { + if (!state.automations[stackId]) { + Vue.set(state.automations, stackId, []) + } + state.automations[stackId].push(automation) + }, + updateAutomation(state, { stackId, automation }) { + const automations = state.automations[stackId] + if (automations) { + const index = automations.findIndex(a => a.id === automation.id) + if (index !== -1) { + Vue.set(automations, index, automation) + } + } + }, + deleteAutomation(state, { stackId, automationId }) { + const automations = state.automations[stackId] + if (automations) { + const index = automations.findIndex(a => a.id === automationId) + if (index !== -1) { + automations.splice(index, 1) + } + } + }, + }, + actions: { + async loadStackAutomations({ commit }, stackId) { + try { + const automations = await apiClient.loadAutomations(stackId) + commit('setAutomations', { stackId, automations }) + return automations + } catch (error) { + console.error('Failed to load automations', error) + throw error + } + }, + async createStackAutomation({ commit }, { stackId, event, actionType, config, order }) { + try { + const automation = await apiClient.createAutomation(stackId, event, actionType, config, order) + commit('addAutomation', { stackId, automation }) + return automation + } catch (error) { + console.error('Failed to create automation', error) + throw error + } + }, + async updateStackAutomation({ commit }, { stackId, id, event, actionType, config, order }) { + try { + const automation = await apiClient.updateAutomation(id, event, actionType, config, order) + commit('updateAutomation', { stackId, automation }) + return automation + } catch (error) { + console.error('Failed to update automation', error) + throw error + } + }, + async deleteStackAutomation({ commit }, { stackId, id }) { + try { + await apiClient.deleteAutomation(id) + commit('deleteAutomation', { stackId, automationId: id }) + } catch (error) { + console.error('Failed to delete automation', error) + throw error + } + }, + }, + } +}