From 1754b60abe24481747d480fdabe041656a853d97 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 16 Jun 2026 12:35:16 +0200 Subject: [PATCH 1/7] feat(migration): add created_at and last_modified_at timestamp columns to deck_board_acl Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- .../Version11002Date20260611000000.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 lib/Migration/Version11002Date20260611000000.php diff --git a/lib/Migration/Version11002Date20260611000000.php b/lib/Migration/Version11002Date20260611000000.php new file mode 100644 index 000000000..79b1cd601 --- /dev/null +++ b/lib/Migration/Version11002Date20260611000000.php @@ -0,0 +1,41 @@ +getTable('deck_board_acl'); + + if (!$table->hasColumn('created_at')) { + $table->addColumn('created_at', 'integer', [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + } + + if (!$table->hasColumn('last_modified_at')) { + $table->addColumn('last_modified_at', 'integer', [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + } + + return $schema; + } +} From 510a8db744a5eac5678a03d240518e7e43ac6cb3 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 16 Jun 2026 12:35:38 +0200 Subject: [PATCH 2/7] feat(migration): backfill board last_modified into deck_board_acl share timestamps on upgrade Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- appinfo/info.xml | 193 +++++++++++++------------ lib/Migration/AclTimestampBackfill.php | 61 ++++++++ 2 files changed, 158 insertions(+), 96 deletions(-) create mode 100644 lib/Migration/AclTimestampBackfill.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 2e534010c..86de1ce94 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,96 +1,97 @@ - - - - deck - Deck - Personal planning and team project organization - Deck is a kanban style organization tool aimed at personal planning and project organization for teams integrated with Nextcloud. - - -- ๐Ÿ“ฅ Add your tasks to cards and put them in order -- ๐Ÿ“„ Write down additional notes in Markdown -- ๐Ÿ”– Assign labels for even better organization -- ๐Ÿ‘ฅ Share with your team, friends or family -- ๐Ÿ“Ž Attach files and embed them in your Markdown description -- ๐Ÿ’ฌ Discuss with your team using comments -- โšก Keep track of changes in the activity stream -- ๐Ÿš€ Get your project organized - - - 4.0.0-dev.1 - agpl - Julius Hรคrtl - Deck - - - - - https://deck.readthedocs.io/en/latest/User_documentation_en/ - https://deck.readthedocs.io/en/latest/API/ - - organization - office - https://github.com/nextcloud/deck - https://github.com/nextcloud/deck/issues - https://github.com/nextcloud/deck.git - https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-1.png - https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-2.png - - pgsql - sqlite - mysql - - - - OCA\Deck\Cron\DeleteCron - OCA\Deck\Cron\ScheduledNotifications - OCA\Deck\Cron\CardDescriptionActivity - OCA\Deck\Cron\SessionsCleanup - - - - OCA\Deck\Migration\DeletedCircleCleanup - - - OCA\Deck\Migration\LabelMismatchCleanup - - - - OCA\Deck\Command\UserExport - OCA\Deck\Command\BoardImport - OCA\Deck\Command\TransferOwnership - OCA\Deck\Command\CalendarToggle - - - - OCA\Deck\Activity\SettingChanges - OCA\Deck\Activity\SettingDescription - OCA\Deck\Activity\SettingComment - - - OCA\Deck\Activity\Filter - - - OCA\Deck\Activity\DeckProvider - - - - OCA\Deck\Provider\DeckProvider - - - - Deck - deck.page.index - deck.svg - 10 - - - - - OCA\Deck\DAV\CalendarPlugin - - - + + + + deck + Deck + Personal planning and team project organization + Deck is a kanban style organization tool aimed at personal planning and project organization for teams integrated with Nextcloud. + + +- ๐Ÿ“ฅ Add your tasks to cards and put them in order +- ๐Ÿ“„ Write down additional notes in Markdown +- ๐Ÿ”– Assign labels for even better organization +- ๐Ÿ‘ฅ Share with your team, friends or family +- ๐Ÿ“Ž Attach files and embed them in your Markdown description +- ๐Ÿ’ฌ Discuss with your team using comments +- โšก Keep track of changes in the activity stream +- ๐Ÿš€ Get your project organized + + + 4.0.0-dev.1 + agpl + Julius Hรคrtl + Deck + + + + + https://deck.readthedocs.io/en/latest/User_documentation_en/ + https://deck.readthedocs.io/en/latest/API/ + + organization + office + https://github.com/nextcloud/deck + https://github.com/nextcloud/deck/issues + https://github.com/nextcloud/deck.git + https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-1.png + https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-2.png + + pgsql + sqlite + mysql + + + + OCA\Deck\Cron\DeleteCron + OCA\Deck\Cron\ScheduledNotifications + OCA\Deck\Cron\CardDescriptionActivity + OCA\Deck\Cron\SessionsCleanup + + + + OCA\Deck\Migration\DeletedCircleCleanup + + + OCA\Deck\Migration\LabelMismatchCleanup + OCA\Deck\Migration\AclTimestampBackfill + + + + OCA\Deck\Command\UserExport + OCA\Deck\Command\BoardImport + OCA\Deck\Command\TransferOwnership + OCA\Deck\Command\CalendarToggle + + + + OCA\Deck\Activity\SettingChanges + OCA\Deck\Activity\SettingDescription + OCA\Deck\Activity\SettingComment + + + OCA\Deck\Activity\Filter + + + OCA\Deck\Activity\DeckProvider + + + + OCA\Deck\Provider\DeckProvider + + + + Deck + deck.page.index + deck.svg + 10 + + + + + OCA\Deck\DAV\CalendarPlugin + + + diff --git a/lib/Migration/AclTimestampBackfill.php b/lib/Migration/AclTimestampBackfill.php new file mode 100644 index 000000000..c0fb5fae2 --- /dev/null +++ b/lib/Migration/AclTimestampBackfill.php @@ -0,0 +1,61 @@ +db->getQueryBuilder(); + $selectQb->select('a.id AS acl_id', 'b.last_modified AS board_last_modified') + ->from('deck_board_acl', 'a') + ->join('a', 'deck_boards', 'b', $selectQb->expr()->eq('b.id', 'a.board_id')) + ->where($selectQb->expr()->eq('a.created_at', $selectQb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + + $result = $selectQb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + if ($rows === []) { + $output->info('AclTimestampBackfill: no rows to update'); + return; + } + + $updateQb = $this->db->getQueryBuilder(); + $updateQb->update('deck_board_acl') + ->set('created_at', $updateQb->createParameter('ts')) + ->set('last_modified_at', $updateQb->createParameter('ts')) + ->where($updateQb->expr()->eq('id', $updateQb->createParameter('acl_id'))); + + $now = time(); + $updated = 0; + foreach ($rows as $row) { + $timestamp = ((int)$row['board_last_modified'] > 0) ? (int)$row['board_last_modified'] : $now; + $updateQb->setParameter('ts', $timestamp, IQueryBuilder::PARAM_INT); + $updateQb->setParameter('acl_id', (int)$row['acl_id'], IQueryBuilder::PARAM_INT); + $updateQb->executeStatement(); + $updated++; + } + + $output->info('AclTimestampBackfill: updated ' . $updated . ' row(s)'); + } +} From ad2c1312ea3521a8ae76a15c1f9360ba128e1870 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 16 Jun 2026 12:37:10 +0200 Subject: [PATCH 3/7] feat(db): record creation and modification timestamps on deck_board_acl insert and update Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/Db/Acl.php | 8 +++++++ lib/Db/AclMapper.php | 21 ++++++++++++++++--- tests/integration/import/ImportExportTest.php | 2 +- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/Db/Acl.php b/lib/Db/Acl.php index 735f9a19f..52aba43b1 100644 --- a/lib/Db/Acl.php +++ b/lib/Db/Acl.php @@ -21,6 +21,10 @@ * @method void setOwner(int $owner) * @method void setToken(string $token) * @method string getToken() + * @method int getCreatedAt() + * @method void setCreatedAt(int $createdAt) + * @method int getLastModifiedAt() + * @method void setLastModifiedAt(int $lastModifiedAt) * */ class Acl extends RelationalEntity { @@ -42,6 +46,8 @@ class Acl extends RelationalEntity { protected $permissionManage = false; protected $owner = false; protected $token = null; + protected $createdAt = 0; + protected $lastModifiedAt = 0; public function __construct() { $this->addType('id', 'integer'); @@ -52,6 +58,8 @@ public function __construct() { $this->addType('type', 'integer'); $this->addType('owner', 'boolean'); $this->addType('token', 'string'); + $this->addType('createdAt', 'integer'); + $this->addType('lastModifiedAt', 'integer'); $this->addRelation('owner'); $this->addResolvable('participant'); } diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index 669ca7a5e..defba19d1 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -8,6 +8,7 @@ namespace OCA\Deck\Db; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -20,7 +21,7 @@ public function __construct(IDBConnection $db) { public function findByAccessToken(string $accessToken) { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->eq('token', $qb->createNamedParameter($accessToken, IQueryBuilder::PARAM_STR))) ->setMaxResults(1); @@ -34,7 +35,7 @@ public function findByAccessToken(string $accessToken) { */ public function findAll(int $boardId, ?int $limit = null, ?int $offset = null) { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) ->setMaxResults($limit) @@ -45,7 +46,7 @@ public function findAll(int $boardId, ?int $limit = null, ?int $offset = null) { public function findIn(array $boardIds, ?int $limit = null, ?int $offset = null): array { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->in('board_id', $qb->createParameter('boardIds'))) ->setMaxResults($limit) @@ -127,4 +128,18 @@ public function findByType(int $type): array { ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))); return $this->findEntities($qb); } + + public function insert(Entity $entity): Entity { + /** @var Acl $entity */ + $now = time(); + $entity->setCreatedAt($now); + $entity->setLastModifiedAt($now); + return parent::insert($entity); + } + + public function update(Entity $entity): Entity { + /** @var Acl $entity */ + $entity->setLastModifiedAt(time()); + return parent::update($entity); + } } diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index a9e00e0cb..586c11c3a 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -134,7 +134,7 @@ public function testReimportOcc() { ); } - public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token']): string { + public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token', 'createdAt', 'lastModifiedAt']): string { $output = ''; $arrayIsList = array_keys($array) === range(0, count($array) - 1); foreach ($array as $key => $value) { From 1f1a8ae9e97ddb4a92ee78d37bf261be4f477561 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 16 Jun 2026 12:38:59 +0200 Subject: [PATCH 4/7] test(migration): verify AclTimestampBackfill backfill logic and AclMapper timestamp behaviour Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- tests/integration/base-query-count.txt | 2 +- tests/unit/Db/AclMapperTest.php | 28 ++++ tests/unit/Db/AclTest.php | 12 +- .../Migration/AclTimestampBackfillTest.php | 158 ++++++++++++++++++ 4 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/unit/Migration/AclTimestampBackfillTest.php diff --git a/tests/integration/base-query-count.txt b/tests/integration/base-query-count.txt index ce8c60422..627806590 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -93102 +96706 \ No newline at end of file diff --git a/tests/unit/Db/AclMapperTest.php b/tests/unit/Db/AclMapperTest.php index af3f03196..0154ee62b 100644 --- a/tests/unit/Db/AclMapperTest.php +++ b/tests/unit/Db/AclMapperTest.php @@ -120,6 +120,34 @@ public function testFindBoardIdDatabase() { $this->assertEquals($this->boards[0]->getId(), $this->aclMapper->findBoardId($this->acls[1]->getId())); } + public function testInsertSetsCreatedAtAndLastModifiedAt(): void { + $before = time(); + $acl = $this->getAcl('user', 'timestamps_user', false, false, false, $this->boards[0]->getId()); + $inserted = $this->aclMapper->insert($acl); + $after = time(); + + $this->assertGreaterThanOrEqual($before, $inserted->getCreatedAt()); + $this->assertLessThanOrEqual($after, $inserted->getCreatedAt()); + $this->assertGreaterThanOrEqual($before, $inserted->getLastModifiedAt()); + $this->assertLessThanOrEqual($after, $inserted->getLastModifiedAt()); + + $this->aclMapper->delete($inserted); + } + + public function testUpdateChangesLastModifiedAtButNotCreatedAt(): void { + $acl = $this->getAcl('user', 'timestamps_user2', false, false, false, $this->boards[0]->getId()); + $inserted = $this->aclMapper->insert($acl); + $originalCreatedAt = $inserted->getCreatedAt(); + + $inserted->setPermissionEdit(true); + $updated = $this->aclMapper->update($inserted); + + $this->assertSame($originalCreatedAt, $updated->getCreatedAt()); + $this->assertGreaterThan(0, $updated->getLastModifiedAt()); + + $this->aclMapper->delete($updated); + } + public function tearDown(): void { parent::tearDown(); foreach ($this->acls as $acl) { diff --git a/tests/unit/Db/AclTest.php b/tests/unit/Db/AclTest.php index e1538215e..069cb98dd 100644 --- a/tests/unit/Db/AclTest.php +++ b/tests/unit/Db/AclTest.php @@ -59,7 +59,9 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); $acl = $this->createAclGroup(); $this->assertEquals([ @@ -70,7 +72,9 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); } @@ -85,7 +89,9 @@ public function testSetOwner() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => true + 'owner' => true, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); } diff --git a/tests/unit/Migration/AclTimestampBackfillTest.php b/tests/unit/Migration/AclTimestampBackfillTest.php new file mode 100644 index 000000000..1cbe6ca66 --- /dev/null +++ b/tests/unit/Migration/AclTimestampBackfillTest.php @@ -0,0 +1,158 @@ +db = $this->createMock(IDBConnection::class); + $this->output = $this->createMock(IOutput::class); + $this->backfill = new AclTimestampBackfill($this->db); + } + + public function testGetName(): void { + $this->assertNotEmpty($this->backfill->getName()); + } + + public function testRunNoRowsIsNoop(): void { + [$selectQb] = $this->buildSelectQb([]); + + $this->db->method('getQueryBuilder')->willReturn($selectQb); + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('no rows')); + + $this->backfill->run($this->output); + } + + public function testRunUpdatesTwoRows(): void { + $rows = [ + ['acl_id' => 1, 'board_last_modified' => 1000000], + ['acl_id' => 2, 'board_last_modified' => 2000000], + ]; + [$selectQb] = $this->buildSelectQb($rows); + $updateQb = $this->buildUpdateQb(2); + + $this->db->expects($this->exactly(2)) + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('2')); + + $this->backfill->run($this->output); + } + + public function testRunUsesBoardTimestampWhenAvailable(): void { + $rows = [['acl_id' => 1, 'board_last_modified' => 1234567]]; + [$selectQb] = $this->buildSelectQb($rows); + + $capturedTs = null; + $updateQb = $this->buildUpdateQb(1, function (string $name, mixed $value) use (&$capturedTs): void { + if ($name === 'ts') { + $capturedTs = $value; + } + }); + + $this->db->expects($this->exactly(2)) + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + $this->output->method('info'); + + $this->backfill->run($this->output); + + $this->assertSame(1234567, $capturedTs); + } + + public function testRunUsesCurrentTimeWhenBoardTimestampIsZero(): void { + $rows = [['acl_id' => 1, 'board_last_modified' => 0]]; + [$selectQb] = $this->buildSelectQb($rows); + + $capturedTs = null; + $before = time(); + $updateQb = $this->buildUpdateQb(1, function (string $name, mixed $value) use (&$capturedTs): void { + if ($name === 'ts') { + $capturedTs = $value; + } + }); + + $this->db->expects($this->exactly(2)) + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + $this->output->method('info'); + + $this->backfill->run($this->output); + $after = time(); + + $this->assertGreaterThanOrEqual($before, $capturedTs); + $this->assertLessThanOrEqual($after, $capturedTs); + } + + /** + * @return array{0: IQueryBuilder&MockObject} + */ + private function buildSelectQb(array $rows): array { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + + $result = $this->createMock(IResult::class); + $result->method('fetchAll')->willReturn($rows); + $result->expects($this->once())->method('closeCursor'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('join')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); + $qb->method('expr')->willReturn($expr); + $qb->method('executeQuery')->willReturn($result); + + return [$qb]; + } + + private function buildUpdateQb(int $expectedExecutions, ?\Closure $onSetParameter = null): IQueryBuilder&MockObject { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('update')->willReturnSelf(); + $qb->method('set')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('createParameter')->willReturn('?'); + $qb->method('expr')->willReturn($expr); + $qb->expects($this->exactly($expectedExecutions))->method('executeStatement'); + + if ($onSetParameter !== null) { + $qb->method('setParameter')->willReturnCallback(function (string $name, mixed $value) use ($onSetParameter, $qb) { + $onSetParameter($name, $value); + return $qb; + }); + } else { + $qb->method('setParameter')->willReturnSelf(); + } + + return $qb; + } +} From 157cce22d96fe7e701a7505ba5c18ec581e262ee Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 16 Jun 2026 15:35:46 +0200 Subject: [PATCH 5/7] perf(migration): replace N+1 updates in AclTimestampBackfill with grouped IN-clause batches Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/Migration/AclTimestampBackfill.php | 29 ++++++---- .../Migration/AclTimestampBackfillTest.php | 57 +++++++++++++------ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/lib/Migration/AclTimestampBackfill.php b/lib/Migration/AclTimestampBackfill.php index c0fb5fae2..aa49688d4 100644 --- a/lib/Migration/AclTimestampBackfill.php +++ b/lib/Migration/AclTimestampBackfill.php @@ -40,20 +40,27 @@ public function run(IOutput $output): void { return; } - $updateQb = $this->db->getQueryBuilder(); - $updateQb->update('deck_board_acl') - ->set('created_at', $updateQb->createParameter('ts')) - ->set('last_modified_at', $updateQb->createParameter('ts')) - ->where($updateQb->expr()->eq('id', $updateQb->createParameter('acl_id'))); - + // Group ACL IDs by target timestamp to issue one UPDATE per group + // rather than one UPDATE per row (avoids N+1 queries). $now = time(); - $updated = 0; + $groups = []; foreach ($rows as $row) { $timestamp = ((int)$row['board_last_modified'] > 0) ? (int)$row['board_last_modified'] : $now; - $updateQb->setParameter('ts', $timestamp, IQueryBuilder::PARAM_INT); - $updateQb->setParameter('acl_id', (int)$row['acl_id'], IQueryBuilder::PARAM_INT); - $updateQb->executeStatement(); - $updated++; + $groups[$timestamp][] = (int)$row['acl_id']; + } + + $updated = 0; + foreach ($groups as $timestamp => $ids) { + // Chunk at 1000 for Oracle compatibility (same limit used by chunkQuery). + foreach (array_chunk($ids, 1000) as $chunk) { + $updateQb = $this->db->getQueryBuilder(); + $updateQb->update('deck_board_acl') + ->set('created_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)) + ->set('last_modified_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)) + ->where($updateQb->expr()->in('id', $updateQb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + $updateQb->executeStatement(); + $updated += count($chunk); + } } $output->info('AclTimestampBackfill: updated ' . $updated . ' row(s)'); diff --git a/tests/unit/Migration/AclTimestampBackfillTest.php b/tests/unit/Migration/AclTimestampBackfillTest.php index 1cbe6ca66..cc9ddc09e 100644 --- a/tests/unit/Migration/AclTimestampBackfillTest.php +++ b/tests/unit/Migration/AclTimestampBackfillTest.php @@ -45,15 +45,16 @@ public function testRunNoRowsIsNoop(): void { $this->backfill->run($this->output); } - public function testRunUpdatesTwoRows(): void { + public function testRunGroupsRowsWithSameTimestampIntoOneUpdate(): void { + // Two ACLs from the same board share the same timestamp โ†’ only 1 UPDATE $rows = [ ['acl_id' => 1, 'board_last_modified' => 1000000], - ['acl_id' => 2, 'board_last_modified' => 2000000], + ['acl_id' => 2, 'board_last_modified' => 1000000], ]; [$selectQb] = $this->buildSelectQb($rows); - $updateQb = $this->buildUpdateQb(2); + $updateQb = $this->buildUpdateQb(1); // single IN-clause UPDATE for both IDs - $this->db->expects($this->exactly(2)) + $this->db->expects($this->exactly(2)) // 1 SELECT + 1 UPDATE ->method('getQueryBuilder') ->willReturnOnConsecutiveCalls($selectQb, $updateQb); @@ -64,13 +65,34 @@ public function testRunUpdatesTwoRows(): void { $this->backfill->run($this->output); } + public function testRunIssuesSeparateUpdatePerDistinctTimestamp(): void { + // Two ACLs from different boards โ†’ two distinct timestamps โ†’ 2 UPDATEs + $rows = [ + ['acl_id' => 1, 'board_last_modified' => 1000000], + ['acl_id' => 2, 'board_last_modified' => 2000000], + ]; + [$selectQb] = $this->buildSelectQb($rows); + $updateQb1 = $this->buildUpdateQb(1); + $updateQb2 = $this->buildUpdateQb(1); + + $this->db->expects($this->exactly(3)) // 1 SELECT + 2 UPDATEs + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb1, $updateQb2); + + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('2')); + + $this->backfill->run($this->output); + } + public function testRunUsesBoardTimestampWhenAvailable(): void { $rows = [['acl_id' => 1, 'board_last_modified' => 1234567]]; [$selectQb] = $this->buildSelectQb($rows); $capturedTs = null; - $updateQb = $this->buildUpdateQb(1, function (string $name, mixed $value) use (&$capturedTs): void { - if ($name === 'ts') { + $updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void { + if ($type === IQueryBuilder::PARAM_INT) { $capturedTs = $value; } }); @@ -91,8 +113,8 @@ public function testRunUsesCurrentTimeWhenBoardTimestampIsZero(): void { $capturedTs = null; $before = time(); - $updateQb = $this->buildUpdateQb(1, function (string $name, mixed $value) use (&$capturedTs): void { - if ($name === 'ts') { + $updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void { + if ($type === IQueryBuilder::PARAM_INT) { $capturedTs = $value; } }); @@ -132,25 +154,26 @@ private function buildSelectQb(array $rows): array { return [$qb]; } - private function buildUpdateQb(int $expectedExecutions, ?\Closure $onSetParameter = null): IQueryBuilder&MockObject { + private function buildUpdateQb(int $expectedExecutions, ?\Closure $onCreateNamedParameter = null): IQueryBuilder&MockObject { $expr = $this->createMock(IExpressionBuilder::class); - $expr->method('eq')->willReturn('1=1'); + $expr->method('in')->willReturn('1=1'); $qb = $this->createMock(IQueryBuilder::class); $qb->method('update')->willReturnSelf(); $qb->method('set')->willReturnSelf(); $qb->method('where')->willReturnSelf(); - $qb->method('createParameter')->willReturn('?'); $qb->method('expr')->willReturn($expr); $qb->expects($this->exactly($expectedExecutions))->method('executeStatement'); - if ($onSetParameter !== null) { - $qb->method('setParameter')->willReturnCallback(function (string $name, mixed $value) use ($onSetParameter, $qb) { - $onSetParameter($name, $value); - return $qb; - }); + if ($onCreateNamedParameter !== null) { + $qb->method('createNamedParameter')->willReturnCallback( + function (mixed $value, int $type) use ($onCreateNamedParameter): string { + $onCreateNamedParameter($value, $type); + return '?'; + } + ); } else { - $qb->method('setParameter')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); } return $qb; From 2278db5c9dbb30534a1d8b35dc2405853bd82a95 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 19 Jun 2026 20:29:55 +0200 Subject: [PATCH 6/7] fix(meta-data): set/update share meta-data on create/update Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/Service/BoardService.php | 4 +++ tests/unit/Service/BoardServiceTest.php | 34 ++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 44bc4e29b..1610352ae 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -400,6 +400,9 @@ public function addAcl(int $boardId, int $type, $participant, bool $edit, bool $ $acl->setPermissionEdit($edit); $acl->setPermissionShare($share); $acl->setPermissionManage($manage); + $now = time(); + $acl->setCreatedAt($now); + $acl->setLastModifiedAt($now); $newAcl = $this->aclMapper->insert($acl); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $newAcl, ActivityManager::SUBJECT_BOARD_SHARE, [], $this->userId); @@ -451,6 +454,7 @@ public function updateAcl(int $id, bool $edit, bool $share, bool $manage): Acl { $acl->setPermissionEdit($edit); $acl->setPermissionShare($share); $acl->setPermissionManage($manage); + $acl->setLastModifiedAt(time()); $this->boardMapper->mapAcl($acl); $acl = $this->aclMapper->update($acl); $this->changeHelper->boardChanged($acl->getBoardId()); diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 549021bf0..e28a133a7 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -301,7 +301,16 @@ public function testAddAcl() { ->method('sendBoardShared'); $this->aclMapper->expects($this->once()) ->method('insert') - ->with($acl) + ->with($this->callback(function (Acl $actual) use ($acl) { + return $actual->getBoardId() === $acl->getBoardId() + && $actual->getType() === $acl->getType() + && $actual->getParticipant() === $acl->getParticipant() + && $actual->getPermissionEdit() === $acl->getPermissionEdit() + && $actual->getPermissionShare() === $acl->getPermissionShare() + && $actual->getPermissionManage() === $acl->getPermissionManage() + && $actual->getCreatedAt() > 0 + && $actual->getLastModifiedAt() > 0; + })) ->willReturn($acl); $this->permissionService->expects($this->any()) ->method('findUsers') @@ -407,11 +416,30 @@ public function testAddAclExtendPermission($currentUserAcl, $providedAcl, $resul $expected = clone $acl; $this->aclMapper->expects($this->once()) ->method('insert') - ->with($acl) + ->with($this->callback(function (Acl $actual) use ($acl) { + return $actual->getBoardId() === $acl->getBoardId() + && $actual->getType() === $acl->getType() + && $actual->getParticipant() === $acl->getParticipant() + && $actual->getPermissionEdit() === $acl->getPermissionEdit() + && $actual->getPermissionShare() === $acl->getPermissionShare() + && $actual->getPermissionManage() === $acl->getPermissionManage() + && $actual->getCreatedAt() > 0 + && $actual->getLastModifiedAt() > 0; + })) ->willReturn($acl); $this->eventDispatcher->expects(self::once()) ->method('dispatchTyped') - ->with(new AclCreatedEvent($acl)); + ->with($this->callback(function (AclCreatedEvent $event) use ($acl) { + $eventAcl = $event->getAcl(); + return $eventAcl->getBoardId() === $acl->getBoardId() + && $eventAcl->getType() === $acl->getType() + && $eventAcl->getParticipant() === $acl->getParticipant() + && $eventAcl->getPermissionEdit() === $acl->getPermissionEdit() + && $eventAcl->getPermissionShare() === $acl->getPermissionShare() + && $eventAcl->getPermissionManage() === $acl->getPermissionManage() + && $eventAcl->getCreatedAt() > 0 + && $eventAcl->getLastModifiedAt() > 0; + })); $this->assertEquals($expected, $this->service->addAcl( 123, Acl::PERMISSION_TYPE_USER, 'admin', $providedAcl[0], $providedAcl[1], $providedAcl[2] )); From 7e3e6032ce1574daae382e1c4221f88a7bacd318 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 19 Jun 2026 20:30:51 +0200 Subject: [PATCH 7/7] docs(version): Bump version to trigger DB migration Signed-off-by: Andy Scherzinger --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 86de1ce94..261f763a5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -20,7 +20,7 @@ - ๐Ÿš€ Get your project organized - 4.0.0-dev.1 + 4.0.0-dev.2 agpl Julius Hรคrtl Deck