Merge pull request 'feat: implement entity patch' (#24) from feat/implement-patch into main

Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
2026-05-21 01:37:40 +00:00
5 changed files with 117 additions and 43 deletions

View File

@@ -797,10 +797,10 @@ class DefaultController extends ControllerAbstract {
if (!is_array($data['targets'])) { if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
} }
if (!isset($data['data'])) { if (!isset($data['properties'])) {
throw new InvalidArgumentException(self::ERR_MISSING_DATA); throw new InvalidArgumentException(self::ERR_MISSING_DATA);
} }
if (!is_array($data['data'])) { if (!is_array($data['properties'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA); 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 { 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 ?? {}); 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 */ /** Helper methods */
get isRead(): boolean { get isRead(): boolean {

View File

@@ -25,6 +25,8 @@ import type {
EntityListStreamRequest, EntityListStreamRequest,
EntityListBulkResponse, EntityListBulkResponse,
EntityListBulkRequest, EntityListBulkRequest,
EntityPatchResponse,
EntityPatchRequest,
} from '../types/entity'; } from '../types/entity';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { EntityObject } from '../models'; import { EntityObject } from '../models';
@@ -150,6 +152,27 @@ export const entityService = {
return createEntityObject(response); 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 * Delete entities by their identifiers
* *

View File

@@ -7,7 +7,7 @@ import { defineStore } from 'pinia'
import { entityService } from '../services' import { entityService } from '../services'
import { EntityObject, MessageObject } from '../models' import { EntityObject, MessageObject } from '../models'
import type { import type {
EntityStreamRequest, EntityListStreamRequest,
EntityTransmitRequest, EntityTransmitRequest,
EntityTransmitResponse, EntityTransmitResponse,
} from '../types/entity' } 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 * 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. * 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 transceiving.value = true
try { try {
const response = await entityService.delete({ sources }) const response = await entityService.delete({ targets })
const successes: EntityIdentifier[] = [] const successes: EntityIdentifier[] = []
const failures: EntityIdentifier[] = [] const failures: EntityIdentifier[] = []
Object.entries(response).forEach(([sourceIdentifier, result]) => { Object.entries(response).forEach(([targetIdentifier, result]) => {
const originalIdentifier = sourceIdentifier as EntityIdentifier const originalIdentifier = targetIdentifier as EntityIdentifier
if (!result.disposition || result.disposition === 'error') { if (!result.disposition || result.disposition === 'error') {
console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`) console.warn(`[Mail Manager][Store] - Entity move on "${originalIdentifier}" returned an error: ${result.error})`)
failures.push(originalIdentifier) 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. * 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 public API
return { return {
// State (readonly) // State (readonly)
@@ -457,12 +487,13 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
list, list,
fetch, fetch,
extant, extant,
fresh,
create, create,
update, update,
patch,
delete: remove, delete: remove,
delta, delta,
move, move,
transmit, transmit,
stream,
} }
}) })

View File

@@ -128,15 +128,30 @@ export interface EntityUpdateRequest<T = MessageInterface> {
export interface EntityUpdateResponse<T = MessageInterface> extends EntityInterface<T> {} 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 * Entity delete
*/ */
export interface EntityDeleteRequest { export interface EntityDeleteRequest {
sources: EntityIdentifier[]; targets: EntityIdentifier[];
} }
export interface EntityDeleteResponse { export interface EntityDeleteResponse {
[sourceIdentifier: EntityIdentifier]: { [targetIdentifier: EntityIdentifier]: {
disposition: 'deleted' | 'moved' | 'error'; disposition: 'deleted' | 'moved' | 'error';
destination: CollectionIdentifier | null; destination: CollectionIdentifier | null;
mutation: EntityIdentifier | null; mutation: EntityIdentifier | null;