243 lines
7.5 KiB
TypeScript
243 lines
7.5 KiB
TypeScript
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;
|
||
}
|
||
}
|