krona-front/src/services/mock/baseRepository.ts

243 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { IRepository, QueryOptions, PagedResult, QueryFilter } from '../contracts/repository';
import {
RequestContext,
TenantAccessDeniedError,
NotFoundError,
createRequestContext,
} from '../contracts/context';
import {
getRecord,
putRecord,
deleteRecord,
getRecordsByEntity,
putStoreItem,
RecordEntity,
} from '../storage/indexedDb';
import { ulid } from '../../shared/lib/ulid';
import { useDemoStore } from '../../stores/demo';
import { BaseEntity } from '../../entities/types';
export class BaseMockRepository<T extends BaseEntity> implements IRepository<T> {
constructor(protected entityName: string) {}
protected resolveContext(context?: RequestContext): RequestContext {
return context || createRequestContext();
}
public async applySimulation(): Promise<void> {
const { simulateLatency, latencyMs, simulateErrors, errorType } = useDemoStore.getState();
if (simulateLatency && latencyMs > 0) {
await new Promise((resolve) => setTimeout(resolve, latencyMs));
}
if (simulateErrors || (errorType && errorType !== 'none')) {
switch (errorType) {
case 'forbidden_403':
throw new Error(`[403 Forbidden]: Отказано в доступе к ресурсу «${this.entityName}».`);
case 'conflict_409':
throw new Error(`[409 Conflict]: Конфликт параллельного изменения записи «${this.entityName}».`);
case 'validation_422':
throw new Error(`[422 Validation Error]: Ошибка валидации структуры записи «${this.entityName}».`);
case 'network_error':
default:
throw new Error(`[Сетевая ошибка]: Не удалось связаться с сервером для «${this.entityName}».`);
}
}
}
async getById(id: string, context?: RequestContext): Promise<T | null> {
await this.applySimulation();
const ctx = this.resolveContext(context);
const res = await getRecord<T & RecordEntity>(id, ctx.tenantId);
if (!res || res.entityType !== this.entityName) {
return null;
}
// Tenant check: strictly prevent data leakage across tenants
const recordTenant = res.tenant_id || 'tenant_main';
if (recordTenant !== ctx.tenantId) {
throw new TenantAccessDeniedError(
`Отказано в доступе: запись «${this.entityName}» с ID ${id} принадлежит тенанту «${recordTenant}» (запрос от «${ctx.tenantId}»).`
);
}
return res;
}
async list(options?: QueryOptions<T>, context?: RequestContext): Promise<PagedResult<T>> {
await this.applySimulation();
const ctx = this.resolveContext(context);
const sortOption = options?.sort
? { field: String(options.sort.field), order: options.sort.order }
: undefined;
const result = await getRecordsByEntity<T & RecordEntity>(this.entityName, {
search: options?.search,
filter: options?.filter as Record<string, unknown>,
sort: sortOption,
page: options?.page,
pageSize: options?.pageSize,
tenantId: ctx.tenantId,
});
return {
items: result.items as T[],
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages,
};
}
async create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T> {
await this.applySimulation();
const ctx = this.resolveContext(context);
const now = useDemoStore.getState().demoDate || new Date().toISOString();
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
const id = ulid();
const entity = {
...data,
id,
entityType: this.entityName,
tenant_id: ctx.tenantId,
updatedAt: now,
audit: {
createdAt: now,
createdById: userId,
updatedAt: now,
updatedById: userId,
version: 1,
tenantId: ctx.tenantId,
correlationId: ctx.correlationId,
},
} as unknown as T & RecordEntity;
await putRecord(entity, ctx.tenantId);
// Write audit log with tenant & correlation tracing
await putStoreItem(
'audit_log',
{
id: ulid(),
entityType: this.entityName,
entityId: id,
tenant_id: ctx.tenantId,
correlation_id: ctx.correlationId,
action: 'create',
userId,
userName: `Роль: ${userId}`,
timestamp: now,
summary: `Создана запись «${this.entityName}» (${id.slice(0, 8)}...)`,
},
ctx.tenantId
);
return entity as T;
}
async update(id: string, data: Partial<T>, context?: RequestContext): Promise<T> {
await this.applySimulation();
const ctx = this.resolveContext(context);
const existing = await this.getById(id, ctx);
if (!existing) {
throw new NotFoundError(`Запись «${this.entityName}» с ID ${id} не найдена.`);
}
const now = useDemoStore.getState().demoDate || new Date().toISOString();
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
const updated = {
...existing,
...data,
id,
entityType: this.entityName,
tenant_id: ctx.tenantId,
updatedAt: now,
audit: {
...existing.audit,
updatedAt: now,
updatedById: userId,
version: (existing.audit?.version || 1) + 1,
tenantId: ctx.tenantId,
correlationId: ctx.correlationId,
},
} as unknown as T & RecordEntity;
await putRecord(updated, ctx.tenantId);
// Write audit log with tenant & correlation tracing
await putStoreItem(
'audit_log',
{
id: ulid(),
entityType: this.entityName,
entityId: id,
tenant_id: ctx.tenantId,
correlation_id: ctx.correlationId,
action: 'update',
userId,
userName: `Роль: ${userId}`,
timestamp: now,
summary: `Обновлена запись «${this.entityName}» (${id.slice(0, 8)}...)`,
},
ctx.tenantId
);
return updated as T;
}
async delete(id: string, context?: RequestContext): Promise<boolean> {
await this.applySimulation();
const ctx = this.resolveContext(context);
const existing = await this.getById(id, ctx);
if (!existing) {
return false;
}
await deleteRecord(id, this.entityName, ctx.tenantId);
const now = useDemoStore.getState().demoDate || new Date().toISOString();
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
// Write audit log with tenant & correlation tracing
await putStoreItem(
'audit_log',
{
id: ulid(),
entityType: this.entityName,
entityId: id,
tenant_id: ctx.tenantId,
correlation_id: ctx.correlationId,
action: 'delete',
userId,
userName: `Роль: ${userId}`,
timestamp: now,
summary: `Удалена запись «${this.entityName}» (${id.slice(0, 8)}...)`,
},
ctx.tenantId
);
return true;
}
async batchUpdate(
items: { id: string; data: Partial<T> }[],
context?: RequestContext
): Promise<T[]> {
const updated: T[] = [];
for (const item of items) {
const res = await this.update(item.id, item.data, context);
updated.push(res);
}
return updated;
}
async count(filter?: QueryFilter, context?: RequestContext): Promise<number> {
await this.applySimulation();
const result = await this.list({ filter }, context);
return result.total;
}
}