feat: implement entity patch #24

Merged
Sebastian merged 1 commits from feat/implement-patch into main 2026-05-21 01:37:40 +00:00
5 changed files with 117 additions and 43 deletions
Showing only changes of commit 453d720046 - Show all commits

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<EntityPatchResponse> {
const properties = {
'@type': request.properties['@type'] ?? 'mail:message',
...(Object.prototype.hasOwnProperty.call(request.properties, 'flags')
? { flags: request.properties.flags }
: {}),
}
return await transceivePost<EntityPatchRequest, EntityPatchResponse>('entity.patch', {
...request,
properties,
});
},
/**
* Delete entities by their identifiers
*

View File

@@ -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,
}
})

View File

@@ -128,15 +128,30 @@ export interface EntityUpdateRequest<T = MessageInterface> {
export interface EntityUpdateResponse<T = MessageInterface> extends EntityInterface<T> {}
/**
* Entity patch
*/
export interface EntityPatchRequest<T = MessageInterface> {
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;