frontend_nyla/src/hooks/useTrustee.ts
ValueOn AG 77e7eba711 BREAKING CHANGE
API and persisted records use PowerOnModel system fields:
- sysCreatedAt, sysCreatedBy, sysModifiedAt, sysModifiedBy
Removed legacy JSON/DB field names:
- _createdAt, _createdBy, _modifiedAt, _modifiedBy
Frontend (frontend_nyla) and gateway call sites were updated accordingly.
Database:
- Bootstrap runs idempotent backfill (_migrateSystemFieldColumns) from old
  underscore columns and selected business duplicates into sys* where sys* IS NULL.
- Re-run app bootstrap against each PostgreSQL database after deploy.
- Optional: DROP INDEX IF EXISTS "idx_invitation_createdby" if an old index remains;
  new index: idx_invitation_syscreatedby on Invitation(sysCreatedBy).
Tests:
- RBAC integration tests aligned with current GROUP mandate filter and UserMandate-based
  UserConnection GROUP clause; buildRbacWhereClause(..., mandateId=...) must be passed
  explicitly (same as production request context).
2026-03-28 18:13:18 +01:00

583 lines
20 KiB
TypeScript

/**
* Trustee Hooks
*
* Hooks für das Trustee-Feature mit Instanz-Kontext.
* Die instanceId wird automatisch aus der URL gelesen.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import { useInstanceId } from './useCurrentInstance';
import {
// Types
type TrusteeOrganisation,
type TrusteeRole,
type TrusteeAccess,
type TrusteeContract,
type TrusteeDocument,
type TrusteePosition,
type PaginationParams,
// Organisation API
fetchOrganisations as fetchOrganisationsApi,
fetchOrganisationById as fetchOrganisationByIdApi,
createOrganisation as createOrganisationApi,
updateOrganisation as updateOrganisationApi,
deleteOrganisation as deleteOrganisationApi,
// Role API
fetchRoles as fetchRolesApi,
fetchRoleById as fetchRoleByIdApi,
createRole as createRoleApi,
updateRole as updateRoleApi,
deleteRole as deleteRoleApi,
// Access API
fetchAccess as fetchAccessApi,
fetchAccessById as fetchAccessByIdApi,
createAccess as createAccessApi,
updateAccess as updateAccessApi,
deleteAccess as deleteAccessApi,
// Contract API
fetchContracts as fetchContractsApi,
fetchContractById as fetchContractByIdApi,
createContract as createContractApi,
updateContract as updateContractApi,
deleteContract as deleteContractApi,
// Document API
fetchDocuments as fetchDocumentsApi,
fetchDocumentById as fetchDocumentByIdApi,
createDocument as createDocumentApi,
updateDocument as updateDocumentApi,
deleteDocument as deleteDocumentApi,
// Position API
fetchPositions as fetchPositionsApi,
fetchPositionById as fetchPositionByIdApi,
createPosition as createPositionApi,
updatePosition as updatePositionApi,
deletePosition as deletePositionApi,
} from '../api/trusteeApi';
export type {
TrusteeOrganisation,
TrusteeRole,
TrusteeAccess,
TrusteeContract,
TrusteeDocument,
TrusteePosition,
PaginationParams
};
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: any[] | string;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
dependsOn?: string;
}
// ============================================================================
// GENERIC TRUSTEE ENTITY HOOK FACTORY
// ============================================================================
interface TrusteeEntityConfig<T> {
entityName: string;
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
update: (request: any, instanceId: string, id: string, data: Partial<T>) => Promise<T>;
deleteItem: (request: any, instanceId: string, id: string) => Promise<void>;
}
function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
return function useTrusteeEntity() {
// Hole instanceId aus URL-Kontext
const instanceId = useInstanceId();
const [items, setItems] = useState<T[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, T[]>();
const { checkPermission } = usePermissions();
const fetchAttributes = useCallback(async () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/attributes/${config.entityName}`);
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error(`Error fetching ${config.entityName} attributes:`, error);
setAttributes([]);
return [];
}
}, [instanceId]);
const fetchPermissions = useCallback(async () => {
try {
// Use fully qualified objectKey for RBAC: data.feature.trustee.EntityName
const objectKey = `data.feature.trustee.${config.entityName}`;
const perms = await checkPermission('DATA', objectKey);
setPermissions(perms);
return perms;
} catch (error: any) {
console.error(`Error fetching ${config.entityName} permissions:`, error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchItems = useCallback(async (params?: PaginationParams) => {
if (!instanceId) {
setItems([]);
return;
}
try {
const data = await config.fetchAll(request, instanceId, params);
if (data && typeof data === 'object' && 'items' in data) {
const fetchedItems = Array.isArray(data.items) ? data.items : [];
setItems(fetchedItems);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const fetchedItems = Array.isArray(data) ? data : [];
setItems(fetchedItems);
setPagination(null);
}
} catch (error: any) {
setItems([]);
setPagination(null);
}
}, [request, instanceId]);
const removeOptimistically = (itemId: string) => {
setItems(prev => prev.filter(item => item.id !== itemId));
};
const updateOptimistically = (itemId: string, updateData: Partial<T>) => {
setItems(prev =>
prev.map(item =>
item.id === itemId
? { ...item, ...updateData }
: item
)
);
};
const fetchById = useCallback(async (itemId: string): Promise<T | null> => {
if (!instanceId) return null;
return await config.fetchById(request, instanceId, itemId);
}, [request, instanceId]);
const generateEditFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) {
return [];
}
return attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
if (attr.name === 'id') {
return false;
}
const nonEditableFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'number') {
fieldType = 'number';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
} else if (attr.type === 'timestamp') {
fieldType = 'readonly';
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required: attr.required === true,
options,
optionsReference,
dependsOn: attr.dependsOn
};
});
}, [attributes]);
const generateCreateFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) {
return [];
}
return attributes
.filter(attr => {
const systemFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId'];
return !systemFields.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'number') {
fieldType = 'number';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
} else if (attr.type === 'timestamp') {
fieldType = 'readonly';
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: true,
required: attr.required === true,
options,
optionsReference,
dependsOn: attr.dependsOn
};
});
}, [attributes]);
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
return await fetchAttributes();
}, [attributes, fetchAttributes]);
// Lade Daten wenn instanceId verfügbar
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchItems();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchItems]);
return {
items,
loading,
error,
refetch: fetchItems,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
instanceId // Auch instanceId zurückgeben für Operations-Hook
};
};
}
function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
return function useTrusteeEntityOperations() {
// Hole instanceId aus URL-Kontext
const instanceId = useInstanceId();
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
const [creatingItem, setCreatingItem] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleDelete = useCallback(async (itemId: string) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingItems(prev => new Set(prev).add(itemId));
try {
await config.deleteItem(request, instanceId, itemId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingItems(prev => {
const newSet = new Set(prev);
newSet.delete(itemId);
return newSet;
});
}
}, [request, instanceId]);
const handleCreate = useCallback(async (itemData: Partial<T>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingItem(true);
console.warn('🔧 handleCreate called with itemData:', itemData);
try {
const newItem = await config.create(request, instanceId, itemData);
return { success: true, data: newItem };
} catch (error: any) {
console.error('🔧 handleCreate error:', {
message: error.message,
response: error.response?.data,
status: error.response?.status
});
const errorMessage = error.response?.data?.detail || error.message;
setCreateError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setCreatingItem(false);
}
}, [request, instanceId]);
const handleUpdate = useCallback(async (itemId: string, updateData: Partial<T>) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedItem = await config.update(request, instanceId, itemId, updateData);
return { success: true, data: updatedItem };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update';
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode: error.response?.status,
isPermissionError: error.response?.status === 403,
isValidationError: error.response?.status === 400
};
}
}, [request, instanceId]);
return {
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
handleDelete,
handleCreate,
handleUpdate,
isLoading,
instanceId
};
};
}
// ============================================================================
// ORGANISATION HOOKS
// ============================================================================
const organisationConfig: TrusteeEntityConfig<TrusteeOrganisation> = {
entityName: 'TrusteeOrganisation',
fetchAll: fetchOrganisationsApi,
fetchById: fetchOrganisationByIdApi,
create: createOrganisationApi,
update: updateOrganisationApi,
deleteItem: deleteOrganisationApi
};
export const useTrusteeOrganisations = _createTrusteeEntityHook(organisationConfig);
export const useTrusteeOrganisationOperations = _createTrusteeOperationsHook(organisationConfig);
// ============================================================================
// ROLE HOOKS
// ============================================================================
const roleConfig: TrusteeEntityConfig<TrusteeRole> = {
entityName: 'TrusteeRole',
fetchAll: fetchRolesApi,
fetchById: fetchRoleByIdApi,
create: createRoleApi,
update: updateRoleApi,
deleteItem: deleteRoleApi
};
export const useTrusteeRoles = _createTrusteeEntityHook(roleConfig);
export const useTrusteeRoleOperations = _createTrusteeOperationsHook(roleConfig);
// ============================================================================
// ACCESS HOOKS
// ============================================================================
const accessConfig: TrusteeEntityConfig<TrusteeAccess> = {
entityName: 'TrusteeAccess',
fetchAll: fetchAccessApi,
fetchById: fetchAccessByIdApi,
create: createAccessApi,
update: updateAccessApi,
deleteItem: deleteAccessApi
};
export const useTrusteeAccess = _createTrusteeEntityHook(accessConfig);
export const useTrusteeAccessOperations = _createTrusteeOperationsHook(accessConfig);
// ============================================================================
// CONTRACT HOOKS
// ============================================================================
const contractConfig: TrusteeEntityConfig<TrusteeContract> = {
entityName: 'TrusteeContract',
fetchAll: fetchContractsApi,
fetchById: fetchContractByIdApi,
create: createContractApi,
update: updateContractApi,
deleteItem: deleteContractApi
};
export const useTrusteeContracts = _createTrusteeEntityHook(contractConfig);
export const useTrusteeContractOperations = _createTrusteeOperationsHook(contractConfig);
// ============================================================================
// DOCUMENT HOOKS
// ============================================================================
const documentConfig: TrusteeEntityConfig<TrusteeDocument> = {
entityName: 'TrusteeDocument',
fetchAll: fetchDocumentsApi,
fetchById: fetchDocumentByIdApi,
create: createDocumentApi,
update: updateDocumentApi,
deleteItem: deleteDocumentApi
};
export const useTrusteeDocuments = _createTrusteeEntityHook(documentConfig);
export const useTrusteeDocumentOperations = _createTrusteeOperationsHook(documentConfig);
// ============================================================================
// POSITION HOOKS
// ============================================================================
const positionConfig: TrusteeEntityConfig<TrusteePosition> = {
entityName: 'TrusteePosition',
fetchAll: fetchPositionsApi,
fetchById: fetchPositionByIdApi,
create: createPositionApi,
update: updatePositionApi,
deleteItem: deletePositionApi
};
export const useTrusteePositions = _createTrusteeEntityHook(positionConfig);
export const useTrusteePositionOperations = _createTrusteeOperationsHook(positionConfig);
export { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from './useTrusteePositionDocuments';
export type { TrusteePositionDocument } from '../api/trusteeApi';