fix: increment composition revision on attachment add and remove

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-08-25 22:10:19 -04:00
parent 684d50062c
commit 0c7f103353
4 changed files with 105 additions and 1 deletions
+4
View File
@@ -582,6 +582,7 @@ class CompositionManager {
$deviceAttachmentsChanged = $uploads !== []; $deviceAttachmentsChanged = $uploads !== [];
$documentAttachmentsChanged = count($failed) < count($documents); $documentAttachmentsChanged = count($failed) < count($documents);
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) { if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed); $composed = $this->markRemoteDirty($composed);
$compositionChanged = true; $compositionChanged = true;
} }
@@ -616,6 +617,7 @@ class CompositionManager {
]; ];
} }
if ($compositionChanged) { if ($compositionChanged) {
$result['revision'] = (int)($composed['revision'] ?? 0);
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0)); $event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event); $this->events->dispatch($event);
} }
@@ -774,8 +776,10 @@ class CompositionManager {
} }
unset($composed['attachments'][$identifier]); unset($composed['attachments'][$identifier]);
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed); $composed = $this->markRemoteDirty($composed);
$result['disposition'] = 'removed'; $result['disposition'] = 'removed';
$result['revision'] = $composed['revision'];
return $composed; return $composed;
}; };
+12 -1
View File
@@ -380,6 +380,10 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
source: attachment.source, source: attachment.source,
} }
}) })
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
} }
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) { 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) { if (response.failed?.length) {
mailUiStore.notify( mailUiStore.notify(
response.failed.length === 1 response.failed.length === 1
@@ -462,8 +470,11 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
if (response.disposition === 'error') { if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to remove attachment:', response.error) console.error('[Mail][Composer] Failed to remove attachment:', response.error)
} }
delete activeDraft.value.attachments[attachment.identifier] delete activeDraft.value.attachments[attachment.identifier]
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
} }
function queueSave(immediate: boolean) { function queueSave(immediate: boolean) {
+2
View File
@@ -111,6 +111,7 @@ export interface CompositionAttachmentAddResponse {
message: string message: string
} }
composition: string composition: string
revision?: number
attachments: Record<string, CompositionAttachmentInterface> attachments: Record<string, CompositionAttachmentInterface>
failed?: { failed?: {
identifier: string identifier: string
@@ -131,4 +132,5 @@ export interface CompositionAttachmentRemoveResponse {
} }
composition: string composition: string
identifier: string identifier: string
revision?: number
} }
+87
View File
@@ -187,6 +187,7 @@ final class CompositionManagerTest extends TestCase {
$this->assertSame('added', $result['disposition']); $this->assertSame('added', $result['disposition']);
$this->assertCount(1, $result['attachments']); $this->assertCount(1, $result['attachments']);
$this->assertSame([], $result['failed']); $this->assertSame([], $result['failed']);
$this->assertSame(1, $result['revision']);
$attachment = $result['attachments']['attach-1']; $attachment = $result['attachments']['attach-1'];
$this->assertSame('documents', $attachment['origin']); $this->assertSame('documents', $attachment['origin']);
@@ -196,11 +197,58 @@ final class CompositionManagerTest extends TestCase {
$this->assertSame(strlen('hello world'), $attachment['size']); $this->assertSame(strlen('hello world'), $attachment['size']);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId); $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame(1, $snapshot['revision']);
$this->assertSame('dirty', $snapshot['remote']['status']); $this->assertSame('dirty', $snapshot['remote']['status']);
$this->assertNull($snapshot['remote']['entity']); $this->assertNull($snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']); $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 { public function testAttachmentAddFromDocumentsPartialFailure(): void {
$compositionId = $this->stageEmptyComposition(); $compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $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']); $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( private function makeManager(
Stub $documentsManager, Stub $documentsManager,
bool $documentsModuleEnabled = true, bool $documentsModuleEnabled = true,