feat: implement save mechanisim
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
@@ -182,11 +182,92 @@ class CompositionManager {
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function save(string $tenantId, string $userId, string $identifier, array $data): array {
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$saveComposition = function (?array $composed) use ($data): ?array {
|
||||
if ($composed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($data['revision']) && is_int($data['revision'])) {
|
||||
$composed['revision'] = $data['revision'];
|
||||
}
|
||||
$composed['sender'] = $data['sender'];
|
||||
$composed['message'] = $data['message'];
|
||||
return $this->markRemoteDirty($composed);
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$saveComposition,
|
||||
);
|
||||
if ($composed === null) {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$synchronized = $this->synchronize($tenantId, $userId, $identifier);
|
||||
if ($synchronized['disposition'] !== 'saved') {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_save_failed',
|
||||
'message' => (string)($synchronized['error'] ?? 'Draft could not be saved to the server'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||
return ['identifier' => $identifier, 'disposition' => 'saved'];
|
||||
}
|
||||
|
||||
public function discard(string $tenantId, string $userId, string $identifier): array {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier),
|
||||
];
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
||||
if ($composed === null) {
|
||||
return ['identifier' => $identifier, 'disposition' => 'discarded'];
|
||||
}
|
||||
|
||||
try {
|
||||
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
|
||||
if ($remoteEntity !== null) {
|
||||
$outcomes = $this->mailManager->entityDelete($tenantId, $userId, $remoteEntity);
|
||||
$outcome = $outcomes[(string)$remoteEntity] ?? reset($outcomes);
|
||||
if (!is_array($outcome) || ($outcome['disposition'] ?? 'error') === 'error') {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_discard_failed',
|
||||
'message' => is_array($outcome)
|
||||
? (string)($outcome['error'] ?? 'Remote draft could not be discarded')
|
||||
: 'Remote draft could not be discarded',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_discard_failed',
|
||||
'message' => $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||
return ['identifier' => $identifier, 'disposition' => 'discarded'];
|
||||
}
|
||||
|
||||
public function synchronize(string $tenantId, string $userId, string $identifier): array {
|
||||
|
||||
@@ -42,6 +42,7 @@ final class CompositionController extends ControllerAbstract {
|
||||
$result = match ($operation) {
|
||||
'stage' => $this->stage($tenantId, $userId, $data),
|
||||
'patch' => $this->patch($tenantId, $userId, $data),
|
||||
'save' => $this->save($tenantId, $userId, $data),
|
||||
'discard' => $this->discard($tenantId, $userId, $data),
|
||||
'send' => $this->send($tenantId, $userId, $data),
|
||||
'attachment.add' => $this->attachmentAdd($tenantId, $userId, $data),
|
||||
@@ -108,6 +109,21 @@ final class CompositionController extends ControllerAbstract {
|
||||
|
||||
return $this->compositionManager->patch($tenantId, $userId, $data['identifier'], $data);
|
||||
}
|
||||
|
||||
private function save(string $tenantId, string $userId, array $data): array {
|
||||
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
|
||||
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
|
||||
}
|
||||
if (!isset($data['sender']) || !is_array($data['sender'])) {
|
||||
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
|
||||
}
|
||||
if (!isset($data['message']) || !is_array($data['message'])) {
|
||||
throw new InvalidArgumentException('Invalid parameter: message must be an array');
|
||||
}
|
||||
|
||||
return $this->compositionManager->save($tenantId, $userId, $data['identifier'], $data);
|
||||
}
|
||||
|
||||
private function discard(string $tenantId, string $userId, array $data): array {
|
||||
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
|
||||
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
|
||||
|
||||
@@ -50,6 +50,8 @@ const showCc = ref(false)
|
||||
const showBcc = ref(false)
|
||||
const applyingDraftToEditor = ref(false)
|
||||
const showDocumentSelector = ref(false)
|
||||
const showCloseDialog = ref(false)
|
||||
const closeAction = ref<'save' | 'discard' | null>(null)
|
||||
const documentSelector = computed(() => documentsSelectorComponent())
|
||||
|
||||
const sender = computed({
|
||||
@@ -160,9 +162,32 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// Handlers
|
||||
const handleClose = async () => {
|
||||
await compositionStore.closeDraft()
|
||||
emit('close')
|
||||
const handleClose = () => {
|
||||
showCloseDialog.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
closeAction.value = 'save'
|
||||
try {
|
||||
if (await compositionStore.saveDraft()) {
|
||||
showCloseDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
} finally {
|
||||
closeAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscard = async () => {
|
||||
closeAction.value = 'discard'
|
||||
try {
|
||||
if (await compositionStore.discardDraft()) {
|
||||
showCloseDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
} finally {
|
||||
closeAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
@@ -233,7 +258,9 @@ const isActive = (name: string, attrs?: any) => {
|
||||
:status="stageStatus"
|
||||
:can-send="canSend"
|
||||
:sending="sending"
|
||||
:saving="saving"
|
||||
@close="handleClose"
|
||||
@save="handleSave"
|
||||
@send="handleSend"
|
||||
/>
|
||||
|
||||
@@ -294,6 +321,38 @@ const isActive = (name: string, attrs?: any) => {
|
||||
:selectable="['entity']"
|
||||
@select="handleDocumentsSelected"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="showCloseDialog" max-width="480" persistent>
|
||||
<v-card>
|
||||
<v-card-title>Save this draft?</v-card-title>
|
||||
<v-card-text>
|
||||
Save the draft to your mail server, or discard it permanently.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="closeAction !== null" @click="showCloseDialog = false">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
:loading="closeAction === 'discard'"
|
||||
:disabled="closeAction !== null || saving"
|
||||
@click="handleDiscard"
|
||||
>
|
||||
Discard
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="closeAction === 'save'"
|
||||
:disabled="closeAction !== null || saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
Save and close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ interface Props {
|
||||
status: string
|
||||
canSend: boolean
|
||||
sending: boolean
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
send: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -37,9 +39,20 @@ defineEmits<{
|
||||
{{ status }}
|
||||
</span>
|
||||
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
variant="text"
|
||||
:loading="saving"
|
||||
:disabled="sending"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="!canSend"
|
||||
:disabled="!canSend || saving"
|
||||
:loading="sending"
|
||||
prepend-icon="mdi-send"
|
||||
@click="$emit('send')"
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
CompositionDiscardResponse,
|
||||
CompositionPatchRequest,
|
||||
CompositionPatchResponse,
|
||||
CompositionSaveRequest,
|
||||
CompositionSaveResponse,
|
||||
CompositionSendRequest,
|
||||
CompositionSendResponse,
|
||||
CompositionStageRequest,
|
||||
@@ -50,6 +52,10 @@ const compositionService = {
|
||||
return await post<CompositionPatchRequest, CompositionPatchResponse>('patch', request)
|
||||
},
|
||||
|
||||
async save(request: CompositionSaveRequest): Promise<CompositionSaveResponse> {
|
||||
return await post<CompositionSaveRequest, CompositionSaveResponse>('save', request)
|
||||
},
|
||||
|
||||
async discard(request: CompositionDiscardRequest): Promise<CompositionDiscardResponse> {
|
||||
return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request)
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CompositionAttachmentInterface,
|
||||
CompositionAttachmentRemoveRequest,
|
||||
CompositionPatchRequest,
|
||||
CompositionSaveRequest,
|
||||
CompositionStageRequest,
|
||||
CompositionStageResponse,
|
||||
} from '@/types/composition'
|
||||
@@ -123,10 +124,6 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
stageDraft()
|
||||
}
|
||||
|
||||
function closeDraft() {
|
||||
return discardDraft()
|
||||
}
|
||||
|
||||
async function stageDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return null
|
||||
@@ -189,16 +186,73 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
|
||||
async function discardDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = null
|
||||
}
|
||||
|
||||
const draftId = activeDraft.value.identifier
|
||||
saving.value = true
|
||||
try {
|
||||
await compositionService.discard({ identifier: draftId })
|
||||
const response = await compositionService.discard({ identifier: draftId })
|
||||
if (response.disposition === 'error') {
|
||||
mailUiStore.notify(`Failed to discard draft: ${response.error?.message || 'Unknown error'}`, 'error')
|
||||
return false
|
||||
}
|
||||
activeDraft.value = null
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to discard staged draft:', error)
|
||||
mailUiStore.notify('Failed to discard draft', 'error')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = null
|
||||
}
|
||||
|
||||
const draft = activeDraft.value
|
||||
const request: CompositionSaveRequest = {
|
||||
identifier: draft.identifier,
|
||||
revision: draft.revision,
|
||||
sender: {
|
||||
provider: draft.sender.service.provider,
|
||||
service: draft.sender.service.identifier,
|
||||
address: draft.sender.address,
|
||||
name: draft.sender.label || null,
|
||||
},
|
||||
message: draft.message,
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await compositionService.save(request)
|
||||
if (response.disposition === 'error') {
|
||||
mailUiStore.notify(`Failed to save draft: ${response.error?.message || 'Unknown error'}`, 'error')
|
||||
return false
|
||||
}
|
||||
|
||||
activeDraft.value = null
|
||||
mailUiStore.notify('Draft saved', 'success')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to save draft:', error)
|
||||
mailUiStore.notify('Failed to save draft', 'error')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +533,8 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
stageStatus,
|
||||
senderIdentities,
|
||||
openDraft,
|
||||
closeDraft,
|
||||
saveDraft,
|
||||
discardDraft,
|
||||
sendDraft,
|
||||
updateSender,
|
||||
updateRecipients,
|
||||
|
||||
@@ -58,6 +58,17 @@ export interface CompositionPatchResponse {
|
||||
message: CompositionMessageInterface
|
||||
}
|
||||
|
||||
export interface CompositionSaveRequest extends CompositionPatchRequest {}
|
||||
|
||||
export interface CompositionSaveResponse {
|
||||
identifier: string
|
||||
disposition: 'saved' | 'error'
|
||||
error?: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompositionDiscardRequest {
|
||||
identifier: string
|
||||
}
|
||||
|
||||
@@ -154,6 +154,109 @@ final class CompositionSynchronizationTest extends TestCase {
|
||||
$this->assertNull($snapshot['remote']['entity']);
|
||||
}
|
||||
|
||||
public function testInlineSaveSynchronizesAndPurgesLocalComposition(): void {
|
||||
$this->stageComposition();
|
||||
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
|
||||
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
|
||||
$created = $this->entity('provider', 'service', 'drafts', 'created-inline');
|
||||
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn($service);
|
||||
$mailManager->expects($this->once())
|
||||
->method('entityCreate')
|
||||
->willReturn($created);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->save(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
[
|
||||
'revision' => 2,
|
||||
'sender' => ['address' => 'sender@example.com'],
|
||||
'message' => ['subject' => 'Saved inline'],
|
||||
],
|
||||
);
|
||||
|
||||
$this->assertSame('saved', $result['disposition']);
|
||||
$this->assertNull($this->compositionStore->compositionFetch(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
));
|
||||
}
|
||||
|
||||
public function testInlineSaveFailureKeepsLocalComposition(): void {
|
||||
$this->stageComposition();
|
||||
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('serviceFindByAddress')->willReturn(null);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->save(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
[
|
||||
'revision' => 2,
|
||||
'sender' => ['address' => 'sender@example.com'],
|
||||
'message' => ['subject' => 'Keep this draft'],
|
||||
],
|
||||
);
|
||||
|
||||
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
|
||||
$this->assertSame('error', $result['disposition']);
|
||||
$this->assertSame('composition_save_failed', $result['error']['type']);
|
||||
$this->assertSame('Keep this draft', $snapshot['message']['subject']);
|
||||
$this->assertSame('failed', $snapshot['remote']['status']);
|
||||
}
|
||||
|
||||
public function testDiscardDeletesRemoteDraftBeforePurgingLocalComposition(): void {
|
||||
$remote = 'provider:service:drafts:remote-1';
|
||||
$this->stageComposition($remote);
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->expects($this->once())
|
||||
->method('entityDelete')
|
||||
->with(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$this->callback(static fn(EntityIdentifier $target): bool => (string)$target === $remote),
|
||||
)
|
||||
->willReturn([$remote => ['disposition' => 'deleted']]);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->discard(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
);
|
||||
|
||||
$this->assertSame('discarded', $result['disposition']);
|
||||
$this->assertNull($this->compositionStore->compositionFetch(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
));
|
||||
}
|
||||
|
||||
public function testRemoteDiscardFailureKeepsLocalComposition(): void {
|
||||
$remote = 'provider:service:drafts:remote-1';
|
||||
$this->stageComposition($remote);
|
||||
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
|
||||
$mailManager->method('entityDelete')
|
||||
->willReturn([$remote => ['disposition' => 'error', 'error' => 'Provider unavailable']]);
|
||||
|
||||
$result = $this->compositionManager($mailManager)->discard(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
);
|
||||
|
||||
$this->assertSame('error', $result['disposition']);
|
||||
$this->assertSame('Provider unavailable', $result['error']['message']);
|
||||
$this->assertNotNull($this->compositionStore->compositionFetch(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
self::COMPOSITION_ID,
|
||||
));
|
||||
}
|
||||
|
||||
private function stageComposition(?string $remoteEntity = null): void {
|
||||
$saveComposition = static fn(): array => [
|
||||
'identifier' => self::COMPOSITION_ID,
|
||||
|
||||
Reference in New Issue
Block a user