From 322ee9257311e01e6638ec40d9d08ad663ee5577 Mon Sep 17 00:00:00 2001 From: chandi Langecker Date: Mon, 21 Nov 2022 20:10:27 +0100 Subject: [PATCH 1/6] live updates for boards Signed-off-by: chandi Langecker --- lib/AppInfo/Application.php | 6 +++++ lib/Db/StackMapper.php | 21 ++++++++++++++--- lib/Event/CardUpdatedEvent.php | 12 ++++++++++ lib/Listeners/LiveUpdateListener.php | 34 ++++++++++++++++++++++++---- lib/NotifyPushEvents.php | 1 + lib/Service/CardService.php | 4 +++- src/sessions.js | 12 ++++++++++ src/store/main.js | 7 +++++- 8 files changed, 88 insertions(+), 9 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1363cb299..2579df32e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -154,6 +154,12 @@ public function register(IRegistrationContext $context): void { // Event listening for realtime updates via notify_push $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class); $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardDeletedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclDeletedEvent::class, LiveUpdateListener::class); $context->registerNotifierService(Notifier::class); $context->registerEventListener(LoadAdditionalScriptsEvent::class, ResourceAdditionalScriptsListener::class); diff --git a/lib/Db/StackMapper.php b/lib/Db/StackMapper.php index 624c739e7..162f1ee56 100644 --- a/lib/Db/StackMapper.php +++ b/lib/Db/StackMapper.php @@ -29,16 +29,24 @@ use OCP\Cache\CappedMemoryCache; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\ICache; +use OCP\ICacheFactory; /** @template-extends DeckMapper */ class StackMapper extends DeckMapper implements IPermissionMapper { private CappedMemoryCache $stackCache; private CardMapper $cardMapper; + private ICache $cache; - public function __construct(IDBConnection $db, CardMapper $cardMapper) { + public function __construct( + IDBConnection $db, + CardMapper $cardMapper, + ICacheFactory $cacheFactory + ) { parent::__construct($db, 'deck_stacks', Stack::class); $this->cardMapper = $cardMapper; $this->stackCache = new CappedMemoryCache(); + $this->cache = $cacheFactory->createDistributed('deck-stackMapper'); } @@ -157,12 +165,19 @@ public function isOwner($userId, $id): bool { * @throws \OCP\DB\Exception */ public function findBoardId($id): ?int { + $result = $this->cache->get('findBoardId:' . $id); + if ($result !== null) { + return $result !== false ? $result : null; + } try { $entity = $this->find($id); - return $entity->getBoardId(); + $result = $entity->getBoardId(); } catch (DoesNotExistException $e) { + $result = false; } catch (MultipleObjectsReturnedException $e) { } - return null; + $this->cache->set('findBoardId:' . $id, $result); + + return $result !== false ? $result : null; } } diff --git a/lib/Event/CardUpdatedEvent.php b/lib/Event/CardUpdatedEvent.php index 5e991691f..e5006f8df 100644 --- a/lib/Event/CardUpdatedEvent.php +++ b/lib/Event/CardUpdatedEvent.php @@ -26,5 +26,17 @@ namespace OCA\Deck\Event; +use OCA\Deck\Db\Card; + class CardUpdatedEvent extends ACardEvent { + private $cardBefore; + + public function __construct(Card $card, Card $before = null) { + parent::__construct($card); + $this->cardBefore = $before; + } + + public function getCardBefore() { + return $this->cardBefore; + } } diff --git a/lib/Listeners/LiveUpdateListener.php b/lib/Listeners/LiveUpdateListener.php index 8138488f2..2a4875566 100644 --- a/lib/Listeners/LiveUpdateListener.php +++ b/lib/Listeners/LiveUpdateListener.php @@ -26,7 +26,11 @@ namespace OCA\Deck\Listeners; +use OCA\Deck\Db\StackMapper; use OCA\Deck\NotifyPushEvents; +use OCA\Deck\Event\AAclEvent; +use OCA\Deck\Event\ACardEvent; +use OCA\Deck\Event\CardUpdatedEvent; use OCA\Deck\Event\SessionClosedEvent; use OCA\Deck\Event\SessionCreatedEvent; use OCA\Deck\Service\SessionService; @@ -42,13 +46,15 @@ class LiveUpdateListener implements IEventListener { private LoggerInterface $logger; private SessionService $sessionService; private IRequest $request; + private StackMapper $stackMapper; private $queue; public function __construct( ContainerInterface $container, IRequest $request, LoggerInterface $logger, - SessionService $sessionService + SessionService $sessionService, + StackMapper $stackMapper ) { try { $this->queue = $container->get(IQueue::class); @@ -59,6 +65,7 @@ public function __construct( $this->logger = $logger; $this->sessionService = $sessionService; $this->request = $request; + $this->stackMapper = $stackMapper; } public function handle(Event $event): void { @@ -68,17 +75,36 @@ public function handle(Event $event): void { } try { - // the web frontend is adding the Session-ID as a header on every request + // the web frontend is adding the Session-ID as a header // TODO: verify the token! this currently allows to spoof a token from someone - // else, preventing this person from getting any live updates + // else, preventing this person from getting updates $causingSessionToken = $this->request->getHeader('x-nc-deck-session'); if ( $event instanceof SessionCreatedEvent || - $event instanceof SessionClosedEvent + $event instanceof SessionClosedEvent || + $event instanceof AAclEvent ) { $this->sessionService->notifyAllSessions($this->queue, $event->getBoardId(), NotifyPushEvents::DeckBoardUpdate, [ 'id' => $event->getBoardId() ], $causingSessionToken); + } elseif ($event instanceof ACardEvent) { + $boardId = $this->stackMapper->findBoardId($event->getCard()->getStackId()); + $this->sessionService->notifyAllSessions($this->queue, $boardId, NotifyPushEvents::DeckCardUpdate, [ + 'boardId' => $boardId, + 'cardId' => $event->getCard()->getId() + ], $causingSessionToken); + + // if card got moved to a diferent board, we should notify + // also sessions active on the previous board + if ($event instanceof CardUpdatedEvent && $event->getCardBefore()) { + $previousBoardId = $this->stackMapper->findBoardId($event->getCardBefore()->getStackId()); + if ($boardId !== $previousBoardId) { + $this->sessionService->notifyAllSessions($this->queue, $previousBoardId, NotifyPushEvents::DeckCardUpdate, [ + 'boardId' => $boardId, + 'cardId' => $event->getCard()->getId() + ], $causingSessionToken); + } + } } } catch (\Exception $e) { $this->logger->error('Error when handling live update event', ['exception' => $e]); diff --git a/lib/NotifyPushEvents.php b/lib/NotifyPushEvents.php index f12daa582..e275ea554 100644 --- a/lib/NotifyPushEvents.php +++ b/lib/NotifyPushEvents.php @@ -26,4 +26,5 @@ class NotifyPushEvents { public const DeckBoardUpdate = 'deck_board_update'; + public const DeckCardUpdate = 'deck_card_update'; } diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index b44a094d9..374111cb9 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -353,7 +353,7 @@ public function update($id, $title, $stackId, $type, $owner, $description = '', } $this->changeHelper->cardChanged($card->getId(), true); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore())); return $card; } @@ -443,6 +443,8 @@ public function reorder($id, $stackId, $order) { $result[$card->getOrder()] = $card; } $this->changeHelper->cardChanged($id, false); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card)); + return array_values($result); } diff --git a/src/sessions.js b/src/sessions.js index 942d34084..52cbf948d 100644 --- a/src/sessions.js +++ b/src/sessions.js @@ -52,6 +52,18 @@ hasPush = listen('deck_board_update', (name, body) => { store.dispatch('refreshBoard', currentBoardId) }) +listen('deck_card_update', (name, body) => { + + // ignore update events which we have triggered ourselves + if (isOurSessionToken(body._causingSessionToken)) return + + // only handle update events for the currently open board + const currentBoardId = store.state.currentBoard?.id + if (body.boardId !== currentBoardId) return + + store.dispatch('loadStacks', currentBoardId) +}) + /** * is the notify_push app active and can * provide us with real time updates? diff --git a/src/store/main.js b/src/store/main.js index 7c798dbf6..31b52c84c 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -333,10 +333,15 @@ export default new Vuex.Store({ commit('setAssignableUsers', board.users) }, - async refreshBoard({ commit }, boardId) { + async refreshBoard({ commit, dispatch }, boardId) { const board = await apiClient.loadById(boardId) + const etagHasChanged = board.ETag !== this.state.currentBoard.ETag commit('setCurrentBoard', board) commit('setAssignableUsers', board.users) + + if (etagHasChanged) { + dispatch('loadStacks', boardId) + } }, toggleShowArchived({ commit }) { From 41d8867bdd09d433ce4680d9482260a4f3d1853b Mon Sep 17 00:00:00 2001 From: chandi Langecker Date: Tue, 22 Nov 2022 13:19:58 +0100 Subject: [PATCH 2/6] live updates: listen for stack and board changes Signed-off-by: chandi Langecker --- lib/AppInfo/Application.php | 2 ++ lib/Event/BoardUpdatedEvent.php | 43 ++++++++++++++++++++++++++++ lib/Listeners/LiveUpdateListener.php | 2 ++ lib/Service/BoardService.php | 2 ++ lib/Service/StackService.php | 9 ++++++ 5 files changed, 58 insertions(+) create mode 100644 lib/Event/BoardUpdatedEvent.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2579df32e..477f96f51 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -36,6 +36,7 @@ use OCA\Deck\Event\AclCreatedEvent; use OCA\Deck\Event\AclDeletedEvent; use OCA\Deck\Event\AclUpdatedEvent; +use OCA\Deck\Event\BoardUpdatedEvent; use OCA\Deck\Event\CardCreatedEvent; use OCA\Deck\Event\CardDeletedEvent; use OCA\Deck\Event\CardUpdatedEvent; @@ -154,6 +155,7 @@ public function register(IRegistrationContext $context): void { // Event listening for realtime updates via notify_push $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class); $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(BoardUpdatedEvent::class, LiveUpdateListener::class); $context->registerEventListener(CardCreatedEvent::class, LiveUpdateListener::class); $context->registerEventListener(CardUpdatedEvent::class, LiveUpdateListener::class); $context->registerEventListener(CardDeletedEvent::class, LiveUpdateListener::class); diff --git a/lib/Event/BoardUpdatedEvent.php b/lib/Event/BoardUpdatedEvent.php new file mode 100644 index 000000000..aab6821c5 --- /dev/null +++ b/lib/Event/BoardUpdatedEvent.php @@ -0,0 +1,43 @@ + + * + * @author chandi Langecker + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Event; + +use OCP\EventDispatcher\Event; + +class BoardUpdatedEvent extends Event { + private $boardId; + + public function __construct(int $boardId) { + parent::__construct(); + + $this->boardId = $boardId; + } + + public function getBoardId(): int { + return $this->boardId; + } +} diff --git a/lib/Listeners/LiveUpdateListener.php b/lib/Listeners/LiveUpdateListener.php index 2a4875566..9c71cf3a9 100644 --- a/lib/Listeners/LiveUpdateListener.php +++ b/lib/Listeners/LiveUpdateListener.php @@ -30,6 +30,7 @@ use OCA\Deck\NotifyPushEvents; use OCA\Deck\Event\AAclEvent; use OCA\Deck\Event\ACardEvent; +use OCA\Deck\Event\BoardUpdatedEvent; use OCA\Deck\Event\CardUpdatedEvent; use OCA\Deck\Event\SessionClosedEvent; use OCA\Deck\Event\SessionCreatedEvent; @@ -82,6 +83,7 @@ public function handle(Event $event): void { if ( $event instanceof SessionCreatedEvent || $event instanceof SessionClosedEvent || + $event instanceof BoardUpdatedEvent || $event instanceof AAclEvent ) { $this->sessionService->notifyAllSessions($this->queue, $event->getBoardId(), NotifyPushEvents::DeckBoardUpdate, [ diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 1ef0a42cf..b8389aeef 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -56,6 +56,7 @@ use OCA\Deck\Db\LabelMapper; use OCP\IUserManager; use OCA\Deck\BadRequestException; +use OCA\Deck\Event\BoardUpdatedEvent; use OCA\Deck\Validators\BoardServiceValidator; use OCP\IURLGenerator; use OCP\Server; @@ -379,6 +380,7 @@ public function update($id, $title, $color, $archived) { $this->boardMapper->mapOwner($board); $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_BOARD, $changes, ActivityManager::SUBJECT_BOARD_UPDATE); $this->changeHelper->boardChanged($board->getId()); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($board->getId())); return $board; } diff --git a/lib/Service/StackService.php b/lib/Service/StackService.php index 83ca7d23e..4a21635c5 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -36,10 +36,12 @@ use OCA\Deck\Db\LabelMapper; use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; +use OCA\Deck\Event\BoardUpdatedEvent; use OCA\Deck\Model\CardDetails; use OCA\Deck\NoPermissionException; use OCA\Deck\StatusException; use OCA\Deck\Validators\StackServiceValidator; +use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; class StackService { @@ -55,6 +57,7 @@ class StackService { private ActivityManager $activityManager; private ChangeHelper $changeHelper; private LoggerInterface $logger; + private IEventDispatcher $eventDispatcher; private StackServiceValidator $stackServiceValidator; public function __construct( @@ -70,6 +73,7 @@ public function __construct( ActivityManager $activityManager, ChangeHelper $changeHelper, LoggerInterface $logger, + IEventDispatcher $eventDispatcher, StackServiceValidator $stackServiceValidator ) { $this->stackMapper = $stackMapper; @@ -84,6 +88,7 @@ public function __construct( $this->activityManager = $activityManager; $this->changeHelper = $changeHelper; $this->logger = $logger; + $this->eventDispatcher = $eventDispatcher; $this->stackServiceValidator = $stackServiceValidator; } @@ -237,6 +242,7 @@ public function create($title, $boardId, $order) { ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_CREATE ); $this->changeHelper->boardChanged($boardId); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($boardId)); return $stack; } @@ -265,6 +271,7 @@ public function delete($id) { ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_DELETE ); $this->changeHelper->boardChanged($stack->getBoardId()); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($stack->getBoardId())); $this->enrichStackWithCards($stack); return $stack; @@ -306,6 +313,7 @@ public function update($id, $title, $boardId, $order, $deletedAt) { ActivityManager::DECK_OBJECT_BOARD, $changes, ActivityManager::SUBJECT_STACK_UPDATE ); $this->changeHelper->boardChanged($stack->getBoardId()); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($stack->getBoardId())); return $stack; } @@ -345,6 +353,7 @@ public function reorder($id, $order) { $result[$stack->getOrder()] = $stack; } $this->changeHelper->boardChanged($stackToSort->getBoardId()); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($stackToSort->getBoardId())); return $result; } From 2e6b20d71d1e689294573c3c2167b8531c2f9095 Mon Sep 17 00:00:00 2001 From: chandi Langecker Date: Tue, 22 Nov 2022 13:23:07 +0100 Subject: [PATCH 3/6] live updates: remove deleted cards with loadStacks() and not just append them Signed-off-by: chandi Langecker --- src/store/card.js | 11 +++++++++++ src/store/stack.js | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/store/card.js b/src/store/card.js index a57ba13a0..21c0e299a 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -273,6 +273,17 @@ export default { addNewCard(state, card) { state.cards.push(card) }, + setCards(state, cards) { + const deletedCards = state.cards.filter(_card => { + return cards.findIndex(c => _card.id === c.id) === -1 + }) + for (const card of deletedCards) { + this.commit('deleteCard', card) + } + for (const card of cards) { + this.commit('addCard', card) + } + }, }, actions: { async addCard({ commit }, card) { diff --git a/src/store/stack.js b/src/store/stack.js index f55d6211d..608ffd32b 100644 --- a/src/store/stack.js +++ b/src/store/stack.js @@ -84,14 +84,16 @@ export default { call = 'loadArchivedStacks' } const stacks = await apiClient[call](boardId) + const cards = [] for (const i in stacks) { const stack = stacks[i] for (const j in stack.cards) { - commit('addCard', stack.cards[j]) + cards.push(stack.cards[j]) } delete stack.cards commit('addStack', stack) } + commit('setCards', cards) }, createStack({ commit }, stack) { stack.boardId = this.state.currentBoard.id From 437f5c9ab5a9d33b420d4493e9e8f8fb7f09e4b1 Mon Sep 17 00:00:00 2001 From: chandi Langecker Date: Wed, 4 Jan 2023 14:59:58 +0100 Subject: [PATCH 4/6] chore(psalm): adding missing events for annotation Signed-off-by: chandi Langecker --- lib/Listeners/LiveUpdateListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Listeners/LiveUpdateListener.php b/lib/Listeners/LiveUpdateListener.php index 9c71cf3a9..f099057ce 100644 --- a/lib/Listeners/LiveUpdateListener.php +++ b/lib/Listeners/LiveUpdateListener.php @@ -42,7 +42,7 @@ use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; -/** @template-implements IEventListener */ +/** @template-implements IEventListener */ class LiveUpdateListener implements IEventListener { private LoggerInterface $logger; private SessionService $sessionService; From c03d06746484d90299dc2043103d84c588381203 Mon Sep 17 00:00:00 2001 From: chandi Langecker Date: Wed, 4 Jan 2023 15:01:20 +0100 Subject: [PATCH 5/6] style(sessionlist): less incisive borders Signed-off-by: chandi Langecker --- src/components/SessionList.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/SessionList.vue b/src/components/SessionList.vue index 5e996258d..155a465fe 100644 --- a/src/components/SessionList.vue +++ b/src/components/SessionList.vue @@ -95,8 +95,7 @@ export default { .avatar-wrapper { background-color: #b9b9b9; border-radius: 50%; - border-width: 2px; - border-style: solid; + border: 1px solid var(--color-border-dark); width: var(--size); height: var(--size); text-align: center; From b4eece879d9199c301820d4af853ad020954db7f Mon Sep 17 00:00:00 2001 From: chandi Langecker Date: Thu, 5 Jan 2023 12:18:44 +0100 Subject: [PATCH 6/6] test(unit): fix tests, mostly due to missing boardId's, which are now required for event emitting Signed-off-by: chandi Langecker --- tests/unit/Service/BoardServiceTest.php | 1 + tests/unit/Service/StackServiceTest.php | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 69ac2fa5c..b6a922327 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -219,6 +219,7 @@ public function testCreateDenied() { public function testUpdate() { $board = new Board(); + $board->setId(123); $board->setTitle('MyBoard'); $board->setOwner('admin'); $board->setColor('00ff00'); diff --git a/tests/unit/Service/StackServiceTest.php b/tests/unit/Service/StackServiceTest.php index 78737ab68..84e38173d 100644 --- a/tests/unit/Service/StackServiceTest.php +++ b/tests/unit/Service/StackServiceTest.php @@ -34,6 +34,7 @@ use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; use OCA\Deck\Validators\StackServiceValidator; +use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; use \Test\TestCase; @@ -71,6 +72,8 @@ class StackServiceTest extends TestCase { private $changeHelper; /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ private $logger; + /** @var IEventDispatcher|\PHPUnit\Framework\MockObject\MockObject */ + private $eventDispatcher; /** @var StackServiceValidator|\PHPUnit\Framework\MockObject\MockObject */ private $stackServiceValidator; @@ -88,6 +91,7 @@ public function setUp(): void { $this->activityManager = $this->createMock(ActivityManager::class); $this->changeHelper = $this->createMock(ChangeHelper::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->stackServiceValidator = $this->createMock(StackServiceValidator::class); $this->stackService = new StackService( @@ -103,6 +107,7 @@ public function setUp(): void { $this->activityManager, $this->changeHelper, $this->logger, + $this->eventDispatcher, $this->stackServiceValidator ); } @@ -198,6 +203,7 @@ public function testDelete() { $this->permissionService->expects($this->once())->method('checkPermission'); $stackToBeDeleted = new Stack(); $stackToBeDeleted->setId(1); + $stackToBeDeleted->setBoardId(1); $this->stackMapper->expects($this->once())->method('find')->willReturn($stackToBeDeleted); $this->stackMapper->expects($this->once())->method('update')->willReturn($stackToBeDeleted); $this->cardMapper->expects($this->once())->method('findAll')->willReturn([]); @@ -246,6 +252,7 @@ public function testReorder() { private function createStack($id, $order) { $stack = new Stack(); $stack->setId($id); + $stack->setBoardId(1); $stack->setOrder($order); return $stack; }