From 453d72004672439acf2a0d2c16a3f113fea3ab72 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 20 May 2026 21:37:14 -0400 Subject: [PATCH] feat: implement entity patch Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 6 +- src/models/message.ts | 5 ++ src/services/entityService.ts | 23 ++++++ src/stores/entitiesStore.ts | 107 +++++++++++++++++--------- src/types/entity.ts | 19 ++++- 5 files changed, 117 insertions(+), 43 deletions(-) diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 43ab463..46e8373 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -797,10 +797,10 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['targets'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - if (!isset($data['data'])) { + if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } - if (!is_array($data['data'])) { + if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } @@ -811,7 +811,7 @@ class DefaultController extends ControllerAbstract { } } - return $this->mailManager->entityPatch($tenantId, $userId, $targets, $data['data']); + return $this->mailManager->entityPatch($tenantId, $userId, $data['properties'], ...$targets->all()); } private function entityMove(string $tenantId, string $userId, array $data): mixed { diff --git a/src/models/message.ts b/src/models/message.ts index 8ea9e58..2af8d63 100644 --- a/src/models/message.ts +++ b/src/models/message.ts @@ -123,6 +123,11 @@ export class MessageObject implements MessageModelInterface { return clonePlain(this._data.flags ?? {}); } + // this should be moved to a mutable object, but for now we can allow it here for convenience + set flags(value: { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean }) { + this._data.flags = clonePlain(value); + } + /** Helper methods */ get isRead(): boolean { diff --git a/src/services/entityService.ts b/src/services/entityService.ts index 4d81abb..77aaa39 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -25,6 +25,8 @@ import type { EntityListStreamRequest, EntityListBulkResponse, EntityListBulkRequest, + EntityPatchResponse, + EntityPatchRequest, } from '../types/entity'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { EntityObject } from '../models'; @@ -150,6 +152,27 @@ export const entityService = { return createEntityObject(response); }, + /** + * Patch existing entities with new properties + * + * @param request - patch request parameters + * + * @returns Promise with patch results keyed by target entity identifier + */ + async patch(request: EntityPatchRequest): Promise { + const properties = { + '@type': request.properties['@type'] ?? 'mail:message', + ...(Object.prototype.hasOwnProperty.call(request.properties, 'flags') + ? { flags: request.properties.flags } + : {}), + } + + return await transceivePost('entity.patch', { + ...request, + properties, + }); + }, + /** * Delete entities by their identifiers * diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index ba9290a..e838031 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -7,7 +7,7 @@ import { defineStore } from 'pinia' import { entityService } from '../services' import { EntityObject, MessageObject } from '../models' import type { - EntityStreamRequest, + EntityListStreamRequest, EntityTransmitRequest, EntityTransmitResponse, } from '../types/entity' @@ -216,6 +216,15 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } } + /** + * Create a new empty entity object + * + * @returns New entity object instance + */ + function fresh(): EntityObject { + return new EntityObject() + } + /** * Create a new entity with given collection identifier and properties * @@ -282,19 +291,19 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { * * Removes successfully deleted entities from the local store. * - * @param sources - entity identifiers to delete + * @param targets - entity identifiers to delete * - * @returns Promise with deletion results keyed by source identifier + * @returns Promise with deletion results keyed by target identifier */ - async function remove(sources: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> { + async function remove(targets: EntityIdentifier[]): Promise<{successes: EntityIdentifier[], failures: EntityIdentifier[]}> { transceiving.value = true try { - const response = await entityService.delete({ sources }) + const response = await entityService.delete({ targets }) const successes: EntityIdentifier[] = [] const failures: EntityIdentifier[] = [] - Object.entries(response).forEach(([sourceIdentifier, result]) => { - const originalIdentifier = sourceIdentifier as EntityIdentifier + Object.entries(response).forEach(([targetIdentifier, result]) => { + const originalIdentifier = targetIdentifier as EntityIdentifier if (!result.disposition || result.disposition === 'error') { console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`) failures.push(originalIdentifier) @@ -335,6 +344,57 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } } + /** + * Patch existing entities with new properties + */ + async function patch(properties: MessageInterface | MessageObject, targets: EntityIdentifier[]) { + transceiving.value = true + try { + if (properties instanceof MessageObject) { + properties = properties.toJson() + } + const response = await entityService.patch({ properties, targets }) + const successes: EntityIdentifier[] = [] + const failures: EntityIdentifier[] = [] + + Object.entries(response).forEach(([targetIdentifier, result]) => { + const originalIdentifier = targetIdentifier as EntityIdentifier + if (!result.disposition || result.disposition === 'error') { + console.warn(`[Mail Manager][Store] - Entity patch on "${originalIdentifier}" returned an error: ${result.error})`) + failures.push(originalIdentifier) + return + } + + const cachedEntity = _entities.value[originalIdentifier] + if (!cachedEntity) { + return + } + + if (result.disposition === 'patched') { + const cachedEntityJson = cachedEntity.toJson() + const mutatedEntity = cachedEntity.clone().fromJson({ + ...cachedEntityJson, + properties: { + ...cachedEntityJson.properties, + ...properties, + }, + }) + _entities.value[originalIdentifier] = mutatedEntity + } + + successes.push(originalIdentifier) + }) + + console.debug('[Mail Manager][Store] - Successfully patched', successes.length, 'entities') + return { successes, failures } + } catch (error: any) { + console.error('[Mail Manager][Store] - Failed to patch entities:', error) + throw error + } finally { + transceiving.value = false + } + } + /** * Move entities to another collection. * @@ -414,36 +474,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } } - /** - * Stream entities progressively, merging each entity into the store as it arrives. - * - * Unlike list(), which waits for the full response before updating the store, - * stream() updates reactive state entity-by-entity so UI renders incrementally. - * - * @param sources - optional source selector - * @param filter - optional list filter - * @param sort - optional list sort - * @param range - optional list range - * - * @returns Promise resolving to { total } when the stream completes - */ - async function stream(sources?: CollectionIdentifier[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<{ total: number }> { - transceiving.value = true - try { - const request: EntityStreamRequest = { sources, filter, sort, range } - const result = await entityService.stream(request, (entity: EntityObject) => { - _entities.value[entity.identifier] = entity - }) - console.debug('[Mail Manager][Store] - Successfully streamed', result.total, 'entities') - return result - } catch (error: any) { - console.error('[Mail Manager][Store] - Failed to stream entities:', error) - throw error - } finally { - transceiving.value = false - } - } - // Return public API return { // State (readonly) @@ -457,12 +487,13 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { list, fetch, extant, + fresh, create, update, + patch, delete: remove, delta, move, transmit, - stream, } }) diff --git a/src/types/entity.ts b/src/types/entity.ts index 4a4b539..123ca77 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -128,15 +128,30 @@ export interface EntityUpdateRequest { export interface EntityUpdateResponse extends EntityInterface {} +/** + * Entity patch + */ +export interface EntityPatchRequest { + properties: T; + targets: EntityIdentifier[]; +} + +export interface EntityPatchResponse{ + [targetIdentifier: EntityIdentifier]: { + disposition: 'patched' | 'error'; + error?: string; + }; +} + /** * Entity delete */ export interface EntityDeleteRequest { - sources: EntityIdentifier[]; + targets: EntityIdentifier[]; } export interface EntityDeleteResponse { - [sourceIdentifier: EntityIdentifier]: { + [targetIdentifier: EntityIdentifier]: { disposition: 'deleted' | 'moved' | 'error'; destination: CollectionIdentifier | null; mutation: EntityIdentifier | null;