diff --git a/lib/CompositionManager.php b/lib/CompositionManager.php index ea9a3b3..85d81f5 100644 --- a/lib/CompositionManager.php +++ b/lib/CompositionManager.php @@ -582,6 +582,7 @@ class CompositionManager { $deviceAttachmentsChanged = $uploads !== []; $documentAttachmentsChanged = count($failed) < count($documents); if ($deviceAttachmentsChanged || $documentAttachmentsChanged) { + $composed['revision'] = (int)($composed['revision'] ?? 0) + 1; $composed = $this->markRemoteDirty($composed); $compositionChanged = true; } @@ -616,6 +617,7 @@ class CompositionManager { ]; } if ($compositionChanged) { + $result['revision'] = (int)($composed['revision'] ?? 0); $event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0)); $this->events->dispatch($event); } @@ -774,8 +776,10 @@ class CompositionManager { } unset($composed['attachments'][$identifier]); + $composed['revision'] = (int)($composed['revision'] ?? 0) + 1; $composed = $this->markRemoteDirty($composed); $result['disposition'] = 'removed'; + $result['revision'] = $composed['revision']; return $composed; }; diff --git a/src/stores/mailCompositionStore.ts b/src/stores/mailCompositionStore.ts index 17ea95d..718a34a 100644 --- a/src/stores/mailCompositionStore.ts +++ b/src/stores/mailCompositionStore.ts @@ -380,6 +380,10 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () => source: attachment.source, } }) + + if (response.revision !== undefined) { + activeDraft.value.revision = response.revision + } } async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) { @@ -432,6 +436,10 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () => } }) + if (response.revision !== undefined) { + activeDraft.value.revision = response.revision + } + if (response.failed?.length) { mailUiStore.notify( response.failed.length === 1 @@ -462,8 +470,11 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () => if (response.disposition === 'error') { console.error('[Mail][Composer] Failed to remove attachment:', response.error) } - + delete activeDraft.value.attachments[attachment.identifier] + if (response.revision !== undefined) { + activeDraft.value.revision = response.revision + } } function queueSave(immediate: boolean) { diff --git a/src/types/composition.ts b/src/types/composition.ts index c311fa2..4e3cf3b 100644 --- a/src/types/composition.ts +++ b/src/types/composition.ts @@ -111,6 +111,7 @@ export interface CompositionAttachmentAddResponse { message: string } composition: string + revision?: number attachments: Record failed?: { identifier: string @@ -131,4 +132,5 @@ export interface CompositionAttachmentRemoveResponse { } composition: string identifier: string + revision?: number } \ No newline at end of file diff --git a/tests/php/Unit/CompositionManagerTest.php b/tests/php/Unit/CompositionManagerTest.php index 688e7c8..7037390 100644 --- a/tests/php/Unit/CompositionManagerTest.php +++ b/tests/php/Unit/CompositionManagerTest.php @@ -187,6 +187,7 @@ final class CompositionManagerTest extends TestCase { $this->assertSame('added', $result['disposition']); $this->assertCount(1, $result['attachments']); $this->assertSame([], $result['failed']); + $this->assertSame(1, $result['revision']); $attachment = $result['attachments']['attach-1']; $this->assertSame('documents', $attachment['origin']); @@ -196,11 +197,58 @@ final class CompositionManagerTest extends TestCase { $this->assertSame(strlen('hello world'), $attachment['size']); $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId); + $this->assertSame(1, $snapshot['revision']); $this->assertSame('dirty', $snapshot['remote']['status']); $this->assertNull($snapshot['remote']['entity']); $this->assertNull($snapshot['remote']['error']); } + public function testAttachmentAddIncrementsRevisionOnSubsequentAdd(): void { + $compositionId = $this->stageEmptyComposition(); + $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); + $documentsManager->method('entityFetchBulk')->willReturn([ + $this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'), + $this->makeEntity('default:personal:root:doc2', 'world.txt', 'text/plain'), + ]); + $documentsManager->method('entityReadStream')->willReturnCallback( + fn(): mixed => $this->makeStream('hello world'), + ); + $manager = $this->makeManager($documentsManager); + + $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [ + 'identifier' => 'attach-1', + 'origin' => 'documents', + 'source' => 'default:personal:root:doc1', + ]); + $result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [ + 'identifier' => 'attach-2', + 'origin' => 'documents', + 'source' => 'default:personal:root:doc2', + ]); + + $this->assertSame(2, $result['revision']); + } + + public function testAttachmentAddDoesNotChangeRevisionWhenNothingIsAdded(): void { + $compositionId = $this->stageEmptyComposition(); + $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); + // documents_manager unavailable means the sourced attachment fails + // entirely, so no attachment is actually added to the composition. + $manager = $this->makeManager($documentsManager, documentsModuleEnabled: false); + + $result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [ + 'identifier' => 'attach-1', + 'origin' => 'documents', + 'source' => 'default:personal:root:doc1', + ]); + + $this->assertSame('error', $result['disposition']); + $this->assertArrayNotHasKey('revision', $result); + + $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId); + $this->assertArrayNotHasKey('revision', $snapshot); + } + public function testAttachmentAddFromDocumentsPartialFailure(): void { $compositionId = $this->stageEmptyComposition(); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); @@ -285,6 +333,45 @@ final class CompositionManagerTest extends TestCase { $this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']); } + public function testAttachmentRemoveIncrementsRevision(): void { + $compositionId = $this->stageEmptyComposition(); + $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); + $documentsManager->method('entityFetchBulk')->willReturn([ + $this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'), + ]); + $documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world')); + $manager = $this->makeManager($documentsManager); + + $added = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [ + 'identifier' => 'attach-1', + 'origin' => 'documents', + 'source' => 'default:personal:root:doc1', + ]); + $this->assertSame(1, $added['revision']); + + $result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'attach-1'); + + $this->assertSame('removed', $result['disposition']); + $this->assertSame(2, $result['revision']); + + $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId); + $this->assertSame(2, $snapshot['revision']); + $this->assertArrayNotHasKey('attach-1', $snapshot['attachments']); + $this->assertSame('dirty', $snapshot['remote']['status']); + } + + public function testAttachmentRemoveNotFoundDoesNotChangeRevision(): void { + $compositionId = $this->stageEmptyComposition(); + $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); + $manager = $this->makeManager($documentsManager); + + $result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'missing-attachment'); + + $this->assertSame('error', $result['disposition']); + $this->assertSame('attachment_not_found', $result['error']['type']); + $this->assertArrayNotHasKey('revision', $result); + } + private function makeManager( Stub $documentsManager, bool $documentsModuleEnabled = true,