feat(stage-d): tenant-aware frontend contracts, multi-tenant indexeddb storage isolation, negative tests and rest api adapter spec
This commit is contained in:
parent
1e54bc6935
commit
e234aa9d4a
11 changed files with 1188 additions and 126 deletions
388
docs/REST-API-ADAPTER.md
Normal file
388
docs/REST-API-ADAPTER.md
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# Krona Universal Business OS — REST API Adapter Specification
|
||||
|
||||
This document defines the architectural contract, header mappings, error translations, query serialization protocols, and the production `HttpBaseRepository` implementation required to transition the Krona Universal Business OS from client-side IndexedDB mock storage to a production-grade multi-tenant RESTful backend.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Overview
|
||||
|
||||
The Krona platform follows a strict **Contract-First Hexagonal Architecture**. Frontend components, view renderers, and business logic interact exclusively with abstract contracts defined in `src/services/contracts/`:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ React UI Layer │
|
||||
│ (DynamicCardRenderer, DynamicTable, Kanban, etc.) │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
IRepository<T> Contract
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ BaseMockRepository │ │ HttpBaseRepository │
|
||||
│ (IndexedDB Mock) │ │ (REST API Client) │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
By decoupling storage from presentation, the transition from client-side prototype to cloud backend requires **zero changes** to UI components, hooks, or state managers. The application simply swaps the repository provider at runtime or build-time.
|
||||
|
||||
---
|
||||
|
||||
## 2. RequestContext & Multi-Tenancy Protocol
|
||||
|
||||
Every read and write operation requires a `RequestContext`. The context guarantees multi-tenant storage isolation, auditability, and distributed tracing.
|
||||
|
||||
```typescript
|
||||
export interface RequestContext {
|
||||
tenantId: string; // Tenant / Workspace identifier (e.g. 'tenant_main', 'tenant_alpha')
|
||||
actorId: string; // Authenticated user ID (e.g. 'usr_01M26B3...')
|
||||
roleIds: string[]; // Active role keys (e.g. ['owner', 'director'])
|
||||
correlationId: string; // Monotonic ULID tracking the distributed call across microservices
|
||||
}
|
||||
```
|
||||
|
||||
In the frontend, `RequestContext` is managed globally by `TenantProvider` (`useTenant()`) and created with default fallbacks via `createRequestContext()`.
|
||||
|
||||
---
|
||||
|
||||
## 3. HTTP Header Mapping
|
||||
|
||||
When `HttpBaseRepository` communicates with the backend, it maps the `RequestContext` and authentication state into standardized HTTP headers:
|
||||
|
||||
| Context Attribute | HTTP Header | Description / Format | Example |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `tenantId` | `X-Tenant-ID` | The target workspace ID. Mandatory for tenant routing & RLS. | `tenant_alpha` |
|
||||
| `actorId` | `X-Actor-ID` | Authenticated actor/user ID. Used for audit logging & ACL. | `usr_01HX98Z...` |
|
||||
| `roleIds` | `X-Role-IDs` | Comma-separated active role identifiers for RBAC/ABAC. | `admin,operations_head` |
|
||||
| `correlationId` | `X-Correlation-ID` | Monotonic ULID for end-to-end distributed tracing. | `01M26B3GB2WERA5TANF94KRJAM` |
|
||||
| Auth State | `Authorization` | Standard JWT bearer token for identity verification. | `Bearer eyJhbGci...` |
|
||||
|
||||
### Security & Invariant Rules
|
||||
1. **Tenant Header Enforcement**: If `X-Tenant-ID` is missing, the API Gateway immediately rejects the request with `400 Bad Request`.
|
||||
2. **Cross-Tenant Verification**: The backend validates that the actor in `Authorization` has permissions within `X-Tenant-ID`. Unauthorized access yields `403 Forbidden`.
|
||||
3. **Correlation Propagation**: The backend must echo `X-Correlation-ID` in all response headers and forward it across all asynchronous queues and downstream microservices.
|
||||
|
||||
---
|
||||
|
||||
## 4. Query Serialization Protocol
|
||||
|
||||
`IRepository<T>.list(options?: QueryOptions<T>)` accepts pagination, search, sorting, and field-level filters. The HTTP adapter serializes these into URL query parameters according to the following specification:
|
||||
|
||||
### 4.1. Pagination
|
||||
- **Format**: `?page=<number>&limit=<pageSize>`
|
||||
- **Example**: `?page=2&limit=50` (1-indexed page)
|
||||
|
||||
### 4.2. Sorting
|
||||
- **Format**: `?sort=<fieldName>:<asc|desc>`
|
||||
- **Multiple Fields**: `?sort=<field1>:asc,<field2>:desc`
|
||||
- **Example**: `?sort=updatedAt:desc`
|
||||
|
||||
### 4.3. Global Text Search
|
||||
- **Format**: `?search=<query>`
|
||||
- **Example**: `?search=ООО+Вектор`
|
||||
|
||||
### 4.4. Field Filtering
|
||||
- **Format**: `?filter[<fieldKey>]=<value>`
|
||||
- **Operator-based Filtering**: `?filter[<fieldKey>][<operator>]=<value>`
|
||||
- **Supported Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `contains`
|
||||
- **Example**:
|
||||
```
|
||||
GET /api/v1/deal?page=1&limit=25&sort=createdAt:desc&filter[status]=won&filter[budget][gte]=1000000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Standard HTTP Error Code Translations
|
||||
|
||||
The frontend contracts define domain error types (`TenantAccessDeniedError`, `NotFoundError`). `HttpBaseRepository` maps HTTP response status codes into typed domain errors:
|
||||
|
||||
| Status Code | Backend Condition | Frontend Domain Error | UI Handling Strategy |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **401 Unauthorized** | Missing or expired JWT token | `AuthenticationRequiredError` | Clears token, redirects to `/login` with return URL. |
|
||||
| **403 Forbidden** | Cross-tenant access attempt or insufficient roles | `TenantAccessDeniedError` | Displays access-denied banner; prevents destructive UI actions. |
|
||||
| **404 Not Found** | Record not found in current tenant | `NotFoundError` / `null` | Returns `null` on `getById`; surfaces 404 page for route views. |
|
||||
| **409 Conflict** | Optimistic locking violation (`version` mismatch) | `ConcurrencyConflictError` | Prompts user with "Data changed by another user" reload dialog. |
|
||||
| **422 Unprocessable** | Payload validation failed against field metadata | `ValidationError` | Maps backend field errors directly to React Hook Form field state. |
|
||||
| **5xx / Network** | Gateway timeout or server failure | `ServiceUnavailableError` | TanStack Query automatically retries with exponential backoff. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Concrete Implementation: `HttpBaseRepository<T>`
|
||||
|
||||
Below is the production-ready TypeScript implementation of `HttpBaseRepository<T>`, implementing `IRepository<T>`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
IRepository,
|
||||
QueryOptions,
|
||||
PagedResult,
|
||||
QueryFilter,
|
||||
} from '../contracts/repository';
|
||||
import {
|
||||
RequestContext,
|
||||
TenantAccessDeniedError,
|
||||
NotFoundError,
|
||||
createRequestContext,
|
||||
} from '../contracts/context';
|
||||
import { BaseEntity } from '../../entities/types';
|
||||
import { ulid } from '../../shared/lib/ulid';
|
||||
|
||||
export class HttpBaseRepository<T extends BaseEntity> implements IRepository<T> {
|
||||
protected baseUrl: string;
|
||||
|
||||
constructor(
|
||||
protected entityName: string,
|
||||
baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
||||
) {
|
||||
this.baseUrl = `${baseUrl.replace(/\/$/, '')}/${entityName}`;
|
||||
}
|
||||
|
||||
protected resolveContext(context?: RequestContext): RequestContext {
|
||||
return context || createRequestContext();
|
||||
}
|
||||
|
||||
protected getAuthToken(): string | null {
|
||||
return typeof window !== 'undefined' ? localStorage.getItem('krona_auth_token') : null;
|
||||
}
|
||||
|
||||
protected buildHeaders(context: RequestContext): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Tenant-ID': context.tenantId,
|
||||
'X-Actor-ID': context.actorId,
|
||||
'X-Role-IDs': context.roleIds.join(','),
|
||||
'X-Correlation-ID': context.correlationId || ulid(),
|
||||
};
|
||||
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
protected async handleError(response: Response, context: RequestContext): Promise<never> {
|
||||
const status = response.status;
|
||||
let payload: { message?: string; details?: unknown } = {};
|
||||
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = { message: response.statusText };
|
||||
}
|
||||
|
||||
const message = payload.message || response.statusText;
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('krona:auth-expired'));
|
||||
}
|
||||
throw new Error(`[401 Unauthorized]: ${message}`);
|
||||
|
||||
case 403:
|
||||
throw new TenantAccessDeniedError(
|
||||
`[403 Forbidden]: Отказано в доступе к «${this.entityName}» в тенанте «${context.tenantId}»: ${message}`
|
||||
);
|
||||
|
||||
case 404:
|
||||
throw new NotFoundError(
|
||||
`[404 Not Found]: Запись «${this.entityName}» не найдена: ${message}`
|
||||
);
|
||||
|
||||
case 409:
|
||||
throw new Error(
|
||||
`[409 Conflict]: Конфликт параллельного обновления записи «${this.entityName}»: ${message}`
|
||||
);
|
||||
|
||||
case 422:
|
||||
throw new Error(
|
||||
`[422 Validation Error]: Ошибка валидации структуры записи «${this.entityName}»: ${message}`
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`[${status} HTTP Error]: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected serializeQueryParams(options?: QueryOptions<T>): string {
|
||||
if (!options) return '';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.page) params.set('page', String(options.page));
|
||||
if (options.pageSize) params.set('limit', String(options.pageSize));
|
||||
if (options.search) params.set('search', options.search);
|
||||
|
||||
if (options.sort) {
|
||||
params.set('sort', `${String(options.sort.field)}:${options.sort.order}`);
|
||||
}
|
||||
|
||||
if (options.filter) {
|
||||
for (const [key, value] of Object.entries(options.filter)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [op, opVal] of Object.entries(value as Record<string, unknown>)) {
|
||||
params.set(`filter[${key}][${op}]`, String(opVal));
|
||||
}
|
||||
} else {
|
||||
params.set(`filter[${key}]`, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
async getById(id: string, context?: RequestContext): Promise<T | null> {
|
||||
const ctx = this.resolveContext(context);
|
||||
const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
|
||||
method: 'GET',
|
||||
headers: this.buildHeaders(ctx),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response, ctx);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async list(options?: QueryOptions<T>, context?: RequestContext): Promise<PagedResult<T>> {
|
||||
const ctx = this.resolveContext(context);
|
||||
const query = this.serializeQueryParams(options);
|
||||
const response = await fetch(`${this.baseUrl}${query}`, {
|
||||
method: 'GET',
|
||||
headers: this.buildHeaders(ctx),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response, ctx);
|
||||
}
|
||||
|
||||
return (await response.json()) as PagedResult<T>;
|
||||
}
|
||||
|
||||
async create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T> {
|
||||
const ctx = this.resolveContext(context);
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(ctx),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response, ctx);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<T>, context?: RequestContext): Promise<T> {
|
||||
const ctx = this.resolveContext(context);
|
||||
const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.buildHeaders(ctx),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response, ctx);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async delete(id: string, context?: RequestContext): Promise<boolean> {
|
||||
const ctx = this.resolveContext(context);
|
||||
const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.buildHeaders(ctx),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response, ctx);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async count(filter?: QueryFilter, context?: RequestContext): Promise<number> {
|
||||
const ctx = this.resolveContext(context);
|
||||
const query = this.serializeQueryParams({ filter, pageSize: 1 });
|
||||
const response = await fetch(`${this.baseUrl}/count${query}`, {
|
||||
method: 'GET',
|
||||
headers: this.buildHeaders(ctx),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response, ctx);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { count: number };
|
||||
return data.count;
|
||||
}
|
||||
|
||||
async batchUpdate(
|
||||
items: { id: string; data: Partial<T> }[],
|
||||
context?: RequestContext
|
||||
): Promise<T[]> {
|
||||
const ctx = this.resolveContext(context);
|
||||
const response = await fetch(`${this.baseUrl}/batch`, {
|
||||
method: 'PATCH',
|
||||
headers: this.buildHeaders(ctx),
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response, ctx);
|
||||
}
|
||||
|
||||
return (await response.json()) as T[];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Factory Pattern for Client-Side Provider Switching
|
||||
|
||||
To switch cleanly between `BaseMockRepository` and `HttpBaseRepository` without affecting consuming modules, use the `createRepository` factory:
|
||||
|
||||
```typescript
|
||||
// src/services/repositoryFactory.ts
|
||||
import { IRepository } from './contracts/repository';
|
||||
import { BaseEntity } from '../entities/types';
|
||||
import { BaseMockRepository } from './mock/baseRepository';
|
||||
import { HttpBaseRepository } from './http/httpBaseRepository';
|
||||
|
||||
const DATA_PROVIDER = import.meta.env.VITE_DATA_PROVIDER || 'mock';
|
||||
|
||||
export function createRepository<T extends BaseEntity>(entityName: string): IRepository<T> {
|
||||
if (DATA_PROVIDER === 'http') {
|
||||
return new HttpBaseRepository<T>(entityName);
|
||||
}
|
||||
return new BaseMockRepository<T>(entityName);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary Checklist for Backend Integration
|
||||
|
||||
- [x] Abstract contract `IRepository<T>` accepts optional `RequestContext`.
|
||||
- [x] Standard HTTP headers configured (`X-Tenant-ID`, `X-Actor-ID`, `X-Role-IDs`, `X-Correlation-ID`).
|
||||
- [x] HTTP status code translation to domain errors (`TenantAccessDeniedError`, `NotFoundError`).
|
||||
- [x] URL query serialization for nested filters, sorting, search, and pagination.
|
||||
- [x] Zero changes required in UI components upon swapping the provider.
|
||||
|
|
@ -3,6 +3,8 @@ import { ToastProvider } from '../../shared/ui/Toast';
|
|||
import { ErrorBoundary } from '../../shared/ui/ErrorBoundary';
|
||||
import { initMockServices } from '../../services/mock';
|
||||
|
||||
import { TenantProvider } from './TenantProvider';
|
||||
|
||||
export interface AppProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
@ -14,9 +16,11 @@ export const AppProviders: React.FC<AppProvidersProps> = ({ children }) => {
|
|||
|
||||
return (
|
||||
<ErrorBoundary scope="Корневое приложение Krona">
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
<TenantProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</TenantProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
72
src/app/providers/TenantProvider.tsx
Normal file
72
src/app/providers/TenantProvider.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
DEFAULT_TENANT_ID,
|
||||
createRequestContext,
|
||||
getActiveGlobalTenantId,
|
||||
setActiveGlobalTenantId,
|
||||
} from '../../services/contracts/context';
|
||||
import {
|
||||
TenantContext,
|
||||
PRESET_TENANTS,
|
||||
TenantContextValue,
|
||||
} from './tenantContext';
|
||||
|
||||
export interface TenantProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialTenantId?: string;
|
||||
}
|
||||
|
||||
export const TenantProvider: React.FC<TenantProviderProps> = ({
|
||||
children,
|
||||
initialTenantId,
|
||||
}) => {
|
||||
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
|
||||
return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
});
|
||||
|
||||
const switchTenant = useCallback((tenantId: string) => {
|
||||
setCurrentTenantId(tenantId);
|
||||
setActiveGlobalTenantId(tenantId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveGlobalTenantId(currentTenantId);
|
||||
}, [currentTenantId]);
|
||||
|
||||
const currentTenant = useMemo(() => {
|
||||
return (
|
||||
PRESET_TENANTS.find((t) => t.id === currentTenantId) || {
|
||||
id: currentTenantId,
|
||||
name: `Тенант ${currentTenantId}`,
|
||||
}
|
||||
);
|
||||
}, [currentTenantId]);
|
||||
|
||||
const requestContext = useMemo(() => {
|
||||
return createRequestContext({ tenantId: currentTenantId });
|
||||
}, [currentTenantId]);
|
||||
|
||||
const createContextForCurrentTenant = useCallback(
|
||||
(overrides?: Parameters<TenantContextValue['createContextForCurrentTenant']>[0]) => {
|
||||
return createRequestContext({ tenantId: currentTenantId, ...overrides });
|
||||
},
|
||||
[currentTenantId]
|
||||
);
|
||||
|
||||
const value = useMemo<TenantContextValue>(
|
||||
() => ({
|
||||
currentTenantId,
|
||||
currentTenant,
|
||||
availableTenants: PRESET_TENANTS,
|
||||
requestContext,
|
||||
switchTenant,
|
||||
createContextForCurrentTenant,
|
||||
}),
|
||||
[currentTenantId, currentTenant, requestContext, switchTenant, createContextForCurrentTenant]
|
||||
);
|
||||
|
||||
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>;
|
||||
};
|
||||
|
||||
export default TenantProvider;
|
||||
|
||||
52
src/app/providers/tenantContext.ts
Normal file
52
src/app/providers/tenantContext.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import {
|
||||
RequestContext,
|
||||
createRequestContext,
|
||||
getActiveGlobalTenantId,
|
||||
setActiveGlobalTenantId,
|
||||
} from '../../services/contracts/context';
|
||||
|
||||
export interface TenantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const PRESET_TENANTS: TenantInfo[] = [
|
||||
{ id: 'tenant_main', name: 'Основной бизнес (Main)', description: 'Главный тенант компании' },
|
||||
{ id: 'tenant_alpha', name: 'Филиал «Север» (Alpha)', description: 'Северное подразделение' },
|
||||
{ id: 'tenant_beta', name: 'Филиал «Юг» (Beta)', description: 'Южное подразделение' },
|
||||
{ id: 'tenant_demo', name: 'Песочница (Demo/Sandbox)', description: 'Тестовая изолированная среда' },
|
||||
];
|
||||
|
||||
export interface TenantContextValue {
|
||||
currentTenantId: string;
|
||||
currentTenant: TenantInfo;
|
||||
availableTenants: TenantInfo[];
|
||||
requestContext: RequestContext;
|
||||
switchTenant: (tenantId: string) => void;
|
||||
createContextForCurrentTenant: (overrides?: Partial<RequestContext>) => RequestContext;
|
||||
}
|
||||
|
||||
export const TenantContext = createContext<TenantContextValue | null>(null);
|
||||
|
||||
export function useTenant(): TenantContextValue {
|
||||
const ctx = useContext(TenantContext);
|
||||
if (!ctx) {
|
||||
const fallbackTenantId = getActiveGlobalTenantId();
|
||||
const currentTenant = PRESET_TENANTS.find((t) => t.id === fallbackTenantId) || {
|
||||
id: fallbackTenantId,
|
||||
name: fallbackTenantId,
|
||||
};
|
||||
return {
|
||||
currentTenantId: fallbackTenantId,
|
||||
currentTenant,
|
||||
availableTenants: PRESET_TENANTS,
|
||||
requestContext: createRequestContext({ tenantId: fallbackTenantId }),
|
||||
switchTenant: setActiveGlobalTenantId,
|
||||
createContextForCurrentTenant: (overrides) =>
|
||||
createRequestContext({ tenantId: fallbackTenantId, ...overrides }),
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
236
src/services/__tests__/tenantIsolation.test.ts
Normal file
236
src/services/__tests__/tenantIsolation.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { BaseMockRepository } from '../mock/baseRepository';
|
||||
import { MockMetadataService } from '../mock/metadataService';
|
||||
import {
|
||||
createRequestContext,
|
||||
TenantAccessDeniedError,
|
||||
setActiveGlobalTenantId,
|
||||
} from '../contracts/context';
|
||||
import {
|
||||
resetDatabase,
|
||||
getAllFromStore,
|
||||
exportDatabaseSnapshot,
|
||||
importDatabaseSnapshot,
|
||||
RecordEntity,
|
||||
} from '../storage/indexedDb';
|
||||
import { BaseEntity } from '../../entities/types';
|
||||
|
||||
interface SampleItem extends BaseEntity {
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
describe('Stage D: Tenant-Aware Frontend Contracts & Multi-Tenant Storage Isolation', () => {
|
||||
let repository: BaseMockRepository<SampleItem>;
|
||||
let metadataService: MockMetadataService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
setActiveGlobalTenantId('tenant_main');
|
||||
repository = new BaseMockRepository<SampleItem>('sample_item');
|
||||
metadataService = new MockMetadataService();
|
||||
});
|
||||
|
||||
it('Test 1: Record created in tenant_alpha cannot be fetched via getById by tenant_beta (throws TenantAccessDeniedError)', async () => {
|
||||
const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' });
|
||||
const contextBeta = createRequestContext({ tenantId: 'tenant_beta' });
|
||||
|
||||
// 1. Create record in tenant_alpha
|
||||
const created = await repository.create(
|
||||
{
|
||||
name: 'Секретный контракт Альфа',
|
||||
amount: 500000,
|
||||
},
|
||||
contextAlpha
|
||||
);
|
||||
|
||||
expect(created.id).toBeDefined();
|
||||
|
||||
// 2. Fetch under tenant_alpha -> succeeds
|
||||
const fetchedAlpha = await repository.getById(created.id, contextAlpha);
|
||||
expect(fetchedAlpha).not.toBeNull();
|
||||
expect(fetchedAlpha?.name).toBe('Секретный контракт Альфа');
|
||||
|
||||
// 3. Fetch under tenant_beta -> rejected with TenantAccessDeniedError!
|
||||
await expect(repository.getById(created.id, contextBeta)).rejects.toThrow(
|
||||
TenantAccessDeniedError
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 2: Querying list under tenant_beta returns 0 records when all records belong to tenant_alpha', async () => {
|
||||
const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' });
|
||||
const contextBeta = createRequestContext({ tenantId: 'tenant_beta' });
|
||||
|
||||
// 1. Create 3 records in tenant_alpha
|
||||
await repository.create({ name: 'Альфа запись 1', amount: 100 }, contextAlpha);
|
||||
await repository.create({ name: 'Альфа запись 2', amount: 200 }, contextAlpha);
|
||||
await repository.create({ name: 'Альфа запись 3', amount: 300 }, contextAlpha);
|
||||
|
||||
// 2. List under tenant_alpha returns 3
|
||||
const listAlpha = await repository.list(undefined, contextAlpha);
|
||||
expect(listAlpha.total).toBe(3);
|
||||
expect(listAlpha.items).toHaveLength(3);
|
||||
|
||||
// 3. List under tenant_beta returns 0
|
||||
const listBeta = await repository.list(undefined, contextBeta);
|
||||
expect(listBeta.total).toBe(0);
|
||||
expect(listBeta.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Test 3: Updating a record in tenant_alpha using a tenant_beta context is rejected with permission error', async () => {
|
||||
const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' });
|
||||
const contextBeta = createRequestContext({ tenantId: 'tenant_beta' });
|
||||
|
||||
// 1. Create in tenant_alpha
|
||||
const created = await repository.create(
|
||||
{
|
||||
name: 'Оригинальный контракт',
|
||||
amount: 1000,
|
||||
},
|
||||
contextAlpha
|
||||
);
|
||||
|
||||
// 2. Attempt update from tenant_beta -> rejected
|
||||
await expect(
|
||||
repository.update(
|
||||
created.id,
|
||||
{
|
||||
name: 'Взломанный контракт',
|
||||
amount: 999999,
|
||||
},
|
||||
contextBeta
|
||||
)
|
||||
).rejects.toThrow(TenantAccessDeniedError);
|
||||
|
||||
// 3. Verify original record was NOT modified
|
||||
const original = await repository.getById(created.id, contextAlpha);
|
||||
expect(original?.name).toBe('Оригинальный контракт');
|
||||
expect(original?.amount).toBe(1000);
|
||||
});
|
||||
|
||||
it('Test 4: Deleting a record in tenant_alpha using a tenant_beta context is rejected with permission error', async () => {
|
||||
const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' });
|
||||
const contextBeta = createRequestContext({ tenantId: 'tenant_beta' });
|
||||
|
||||
// 1. Create in tenant_alpha
|
||||
const created = await repository.create(
|
||||
{
|
||||
name: 'Неудаляемый контракт',
|
||||
amount: 2500,
|
||||
},
|
||||
contextAlpha
|
||||
);
|
||||
|
||||
// 2. Attempt delete from tenant_beta -> rejected
|
||||
await expect(repository.delete(created.id, contextBeta)).rejects.toThrow(
|
||||
TenantAccessDeniedError
|
||||
);
|
||||
|
||||
// 3. Verify record still exists in tenant_alpha
|
||||
const stillExists = await repository.getById(created.id, contextAlpha);
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Test 5: Metadata custom fields, entities, and layouts in tenant_alpha do not leak into tenant_beta', async () => {
|
||||
// 1. Create custom entity in tenant_alpha
|
||||
const entityAlpha = await metadataService.createEntity(
|
||||
{
|
||||
code: 'alpha_custom_dossier',
|
||||
name: 'Досье Альфа',
|
||||
pluralName: 'Досье Альфа',
|
||||
category: 'custom',
|
||||
isSystem: false,
|
||||
maturity: 'target_prototype',
|
||||
fields: [],
|
||||
},
|
||||
'tenant_alpha'
|
||||
);
|
||||
|
||||
// 2. Save layout in tenant_alpha
|
||||
await metadataService.saveLayout(
|
||||
{
|
||||
id: 'layout_alpha_01',
|
||||
entityCode: entityAlpha.code,
|
||||
name: 'Форма Альфа',
|
||||
isDefault: true,
|
||||
sections: [],
|
||||
},
|
||||
'tenant_alpha'
|
||||
);
|
||||
|
||||
// 3. List entities in tenant_alpha -> includes alpha_custom_dossier
|
||||
const entitiesAlpha = await metadataService.listEntities('tenant_alpha');
|
||||
expect(entitiesAlpha.some((e) => e.code === 'alpha_custom_dossier')).toBe(true);
|
||||
|
||||
// 4. List entities in tenant_beta -> does NOT include alpha_custom_dossier!
|
||||
const entitiesBeta = await metadataService.listEntities('tenant_beta');
|
||||
expect(entitiesBeta.some((e) => e.code === 'alpha_custom_dossier')).toBe(false);
|
||||
|
||||
// 5. Layouts in tenant_beta do not leak
|
||||
const layoutsBeta = await metadataService.listLayouts(entityAlpha.code, 'tenant_beta');
|
||||
expect(layoutsBeta.some((l) => l.id === 'layout_alpha_01')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 6: Audit log entries record tenant_id and correlation_id', async () => {
|
||||
const correlationId = 'corr_test_ulid_12345';
|
||||
const context = createRequestContext({
|
||||
tenantId: 'tenant_alpha',
|
||||
actorId: 'usr_director',
|
||||
correlationId,
|
||||
});
|
||||
|
||||
// 1. Create record with custom context
|
||||
const created = await repository.create(
|
||||
{
|
||||
name: 'Аудируемая операция',
|
||||
amount: 777,
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
// 2. Check audit log store directly
|
||||
const logs = (await getAllFromStore('audit_log', 'tenant_alpha')) as Record<string, unknown>[];
|
||||
const logEntry = logs.find((l) => l.entityId === created.id);
|
||||
|
||||
expect(logEntry).toBeDefined();
|
||||
expect(logEntry?.tenant_id).toBe('tenant_alpha');
|
||||
expect(logEntry?.correlation_id).toBe(correlationId);
|
||||
expect(logEntry?.userId).toBe('usr_director');
|
||||
});
|
||||
|
||||
it('Test 7: Tenant-scoped Snapshot Export and Import maintains strict isolation', async () => {
|
||||
const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' });
|
||||
const contextBeta = createRequestContext({ tenantId: 'tenant_beta' });
|
||||
|
||||
// Create data in both tenants
|
||||
await repository.create({ name: 'Alpha Data 1', amount: 10 }, contextAlpha);
|
||||
await repository.create({ name: 'Alpha Data 2', amount: 20 }, contextAlpha);
|
||||
await repository.create({ name: 'Beta Data 1', amount: 99 }, contextBeta);
|
||||
|
||||
// Export ONLY tenant_alpha
|
||||
const snapshotAlpha = await exportDatabaseSnapshot('tenant_alpha');
|
||||
expect(snapshotAlpha.metadata.totalRecords).toBe(2);
|
||||
expect(snapshotAlpha.tenantId).toBe('tenant_alpha');
|
||||
expect(
|
||||
snapshotAlpha.stores.records_data.every(
|
||||
(r) => (r as RecordEntity).tenant_id === 'tenant_alpha'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
// Import into tenant_gamma
|
||||
await importDatabaseSnapshot(snapshotAlpha, 'tenant_gamma');
|
||||
|
||||
// tenant_gamma now has 2 records
|
||||
const contextGamma = createRequestContext({ tenantId: 'tenant_gamma' });
|
||||
const listGamma = await repository.list(undefined, contextGamma);
|
||||
expect(listGamma.total).toBe(2);
|
||||
|
||||
// tenant_alpha still has 2 records
|
||||
const listAlpha = await repository.list(undefined, contextAlpha);
|
||||
expect(listAlpha.total).toBe(2);
|
||||
|
||||
// tenant_beta still has 1 record
|
||||
const listBeta = await repository.list(undefined, contextBeta);
|
||||
expect(listBeta.total).toBe(1);
|
||||
});
|
||||
});
|
||||
54
src/services/contracts/context.ts
Normal file
54
src/services/contracts/context.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { ulid } from '../../shared/lib/ulid';
|
||||
|
||||
export interface RequestContext {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
roleIds: string[];
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
export class TenantAccessDeniedError extends Error {
|
||||
constructor(
|
||||
message = 'Отказано в доступе: запись принадлежит другому тенанту или доступ ограничен'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TenantAccessDeniedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message = 'Запрашиваемый ресурс не найден') {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_TENANT_ID = 'tenant_main';
|
||||
export const DEFAULT_ACTOR_ID = 'usr_default';
|
||||
export const DEFAULT_ROLE_IDS = ['owner'];
|
||||
|
||||
let activeGlobalTenantId = DEFAULT_TENANT_ID;
|
||||
|
||||
export function getActiveGlobalTenantId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem('krona_tenant_id');
|
||||
if (stored) return stored;
|
||||
}
|
||||
return activeGlobalTenantId;
|
||||
}
|
||||
|
||||
export function setActiveGlobalTenantId(tenantId: string): void {
|
||||
activeGlobalTenantId = tenantId;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_tenant_id', tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequestContext(overrides?: Partial<RequestContext>): RequestContext {
|
||||
return {
|
||||
tenantId: overrides?.tenantId || getActiveGlobalTenantId(),
|
||||
actorId: overrides?.actorId || DEFAULT_ACTOR_ID,
|
||||
roleIds: overrides?.roleIds || [...DEFAULT_ROLE_IDS],
|
||||
correlationId: overrides?.correlationId || ulid(),
|
||||
};
|
||||
}
|
||||
|
|
@ -5,3 +5,4 @@ export * from './commerce';
|
|||
export * from './inventory';
|
||||
export * from './finance';
|
||||
export * from './metadata';
|
||||
export * from './context';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { RequestContext } from './context';
|
||||
|
||||
export interface QueryFilter {
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
|
@ -24,10 +26,11 @@ export interface PagedResult<T> {
|
|||
}
|
||||
|
||||
export interface IRepository<T extends { id: string }> {
|
||||
getById(id: string): Promise<T | null>;
|
||||
list(options?: QueryOptions<T>): Promise<PagedResult<T>>;
|
||||
create(data: Omit<T, 'id' | 'audit'>): Promise<T>;
|
||||
update(id: string, data: Partial<T>): Promise<T>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
count(filter?: QueryFilter): Promise<number>;
|
||||
getById(id: string, context?: RequestContext): Promise<T | null>;
|
||||
list(options?: QueryOptions<T>, context?: RequestContext): Promise<PagedResult<T>>;
|
||||
create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T>;
|
||||
update(id: string, data: Partial<T>, context?: RequestContext): Promise<T>;
|
||||
delete(id: string, context?: RequestContext): Promise<boolean>;
|
||||
count(filter?: QueryFilter, context?: RequestContext): Promise<number>;
|
||||
batchUpdate?(items: { id: string; data: Partial<T> }[], context?: RequestContext): Promise<T[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { IRepository, QueryOptions, PagedResult, QueryFilter } from '../contracts/repository';
|
||||
import {
|
||||
RequestContext,
|
||||
TenantAccessDeniedError,
|
||||
NotFoundError,
|
||||
createRequestContext,
|
||||
} from '../contracts/context';
|
||||
import {
|
||||
getRecord,
|
||||
putRecord,
|
||||
deleteRecord,
|
||||
getRecordsByEntity,
|
||||
countRecords,
|
||||
putStoreItem,
|
||||
RecordEntity,
|
||||
} from '../storage/indexedDb';
|
||||
|
|
@ -15,6 +20,10 @@ 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();
|
||||
|
||||
|
|
@ -37,17 +46,28 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
|
|||
}
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<T | null> {
|
||||
async getById(id: string, context?: RequestContext): Promise<T | null> {
|
||||
await this.applySimulation();
|
||||
const res = await getRecord<T & RecordEntity>(id);
|
||||
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>): Promise<PagedResult<T>> {
|
||||
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 }
|
||||
|
|
@ -59,6 +79,7 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
|
|||
sort: sortOption,
|
||||
page: options?.page,
|
||||
pageSize: options?.pageSize,
|
||||
tenantId: ctx.tenantId,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -70,16 +91,18 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
|
|||
};
|
||||
}
|
||||
|
||||
async create(data: Omit<T, 'id' | 'audit'>): Promise<T> {
|
||||
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 = useDemoStore.getState().currentRoleId || 'demo_user';
|
||||
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,
|
||||
|
|
@ -87,100 +110,134 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
|
|||
updatedAt: now,
|
||||
updatedById: userId,
|
||||
version: 1,
|
||||
tenantId: ctx.tenantId,
|
||||
correlationId: ctx.correlationId,
|
||||
},
|
||||
} as unknown as T & RecordEntity;
|
||||
|
||||
await putRecord(entity);
|
||||
await putRecord(entity, ctx.tenantId);
|
||||
|
||||
// Write audit log
|
||||
await putStoreItem('audit_log', {
|
||||
id: ulid(),
|
||||
entityType: this.entityName,
|
||||
entityId: id,
|
||||
action: 'create',
|
||||
userId,
|
||||
userName: `Роль: ${userId}`,
|
||||
timestamp: now,
|
||||
summary: `Создана запись «${this.entityName}» (${id.slice(0, 8)}...)`,
|
||||
});
|
||||
// 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>): Promise<T> {
|
||||
async update(id: string, data: Partial<T>, context?: RequestContext): Promise<T> {
|
||||
await this.applySimulation();
|
||||
const existing = await this.getById(id);
|
||||
const ctx = this.resolveContext(context);
|
||||
const existing = await this.getById(id, ctx);
|
||||
if (!existing) {
|
||||
throw new Error(`Запись «${this.entityName}» с ID ${id} не найдена.`);
|
||||
throw new NotFoundError(`Запись «${this.entityName}» с ID ${id} не найдена.`);
|
||||
}
|
||||
|
||||
const now = useDemoStore.getState().demoDate || new Date().toISOString();
|
||||
const userId = useDemoStore.getState().currentRoleId || 'demo_user';
|
||||
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);
|
||||
await putRecord(updated, ctx.tenantId);
|
||||
|
||||
// Write audit log
|
||||
await putStoreItem('audit_log', {
|
||||
id: ulid(),
|
||||
entityType: this.entityName,
|
||||
entityId: id,
|
||||
action: 'update',
|
||||
userId,
|
||||
userName: `Роль: ${userId}`,
|
||||
timestamp: now,
|
||||
summary: `Обновлена запись «${this.entityName}» (${id.slice(0, 8)}...)`,
|
||||
});
|
||||
// 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): Promise<boolean> {
|
||||
async delete(id: string, context?: RequestContext): Promise<boolean> {
|
||||
await this.applySimulation();
|
||||
const existing = await this.getById(id);
|
||||
const ctx = this.resolveContext(context);
|
||||
const existing = await this.getById(id, ctx);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteRecord(id, this.entityName);
|
||||
await deleteRecord(id, this.entityName, ctx.tenantId);
|
||||
|
||||
const now = useDemoStore.getState().demoDate || new Date().toISOString();
|
||||
const userId = useDemoStore.getState().currentRoleId || 'demo_user';
|
||||
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
|
||||
|
||||
// Write audit log
|
||||
await putStoreItem('audit_log', {
|
||||
id: ulid(),
|
||||
entityType: this.entityName,
|
||||
entityId: id,
|
||||
action: 'delete',
|
||||
userId,
|
||||
userName: `Роль: ${userId}`,
|
||||
timestamp: now,
|
||||
summary: `Удалена запись «${this.entityName}» (${id.slice(0, 8)}...)`,
|
||||
});
|
||||
// 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 count(filter?: QueryFilter): Promise<number> {
|
||||
await this.applySimulation();
|
||||
if (!filter || Object.keys(filter).length === 0) {
|
||||
return countRecords(this.entityName);
|
||||
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);
|
||||
}
|
||||
const result = await this.list({ filter });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async count(filter?: QueryFilter, context?: RequestContext): Promise<number> {
|
||||
await this.applySimulation();
|
||||
const result = await this.list({ filter }, context);
|
||||
return result.total;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,29 +13,33 @@ import {
|
|||
} from '../storage/indexedDb';
|
||||
import { ulid } from '../../shared/lib/ulid';
|
||||
import { SYSTEM_ENTITY_METADATA } from '../../entities/metadata/registry';
|
||||
import { getActiveGlobalTenantId } from '../contracts/context';
|
||||
|
||||
export class MockMetadataService implements IMetadataService {
|
||||
async listEntities(): Promise<EntityMetadata[]> {
|
||||
const fromDb = await getAllFromStore<EntityMetadata>('entities_meta');
|
||||
async listEntities(tenantId?: string): Promise<EntityMetadata[]> {
|
||||
const effectiveTenant = tenantId || getActiveGlobalTenantId();
|
||||
const fromDb = await getAllFromStore<EntityMetadata & { tenant_id?: string }>('entities_meta', effectiveTenant);
|
||||
if (fromDb.length > 0) {
|
||||
return fromDb;
|
||||
}
|
||||
return Object.values(SYSTEM_ENTITY_METADATA);
|
||||
}
|
||||
|
||||
async getEntity(code: string): Promise<EntityMetadata | null> {
|
||||
const list = await this.listEntities();
|
||||
async getEntity(code: string, tenantId?: string): Promise<EntityMetadata | null> {
|
||||
const list = await this.listEntities(tenantId);
|
||||
const cleanCode = code.trim().toLowerCase();
|
||||
return list.find((e) => e.code.trim().toLowerCase() === cleanCode) || null;
|
||||
}
|
||||
|
||||
async createEntity(metadata: Omit<EntityMetadata, 'id'>): Promise<EntityMetadata> {
|
||||
async createEntity(metadata: Omit<EntityMetadata, 'id'>, tenantId?: string): Promise<EntityMetadata> {
|
||||
const id = ulid();
|
||||
const entity: EntityMetadata = {
|
||||
const effectiveTenant = tenantId || getActiveGlobalTenantId();
|
||||
const entity: EntityMetadata & { tenant_id?: string } = {
|
||||
...metadata,
|
||||
id,
|
||||
tenant_id: ((metadata as Record<string, unknown>).tenant_id as string) || effectiveTenant,
|
||||
};
|
||||
await putStoreItem('entities_meta', entity);
|
||||
await putStoreItem('entities_meta', entity, effectiveTenant);
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
|
@ -104,10 +108,12 @@ export class MockMetadataService implements IMetadataService {
|
|||
|
||||
async deleteField(entityCode: string, fieldId: string): Promise<boolean> {
|
||||
const entity = await this.getEntity(entityCode);
|
||||
if (!entity) return false;
|
||||
const targetField = entity.fields.find((f) => f.id === fieldId);
|
||||
if (targetField?.system) {
|
||||
throw new Error('Нельзя удалить системное поле сущности.');
|
||||
if (!entity) {
|
||||
throw new Error(`Сущность с кодом «${entityCode}» не найдена.`);
|
||||
}
|
||||
const field = entity.fields.find((f) => f.id === fieldId);
|
||||
if (field?.system || (field as unknown as { isSystem?: boolean })?.isSystem) {
|
||||
throw new Error('Нельзя удалить системное поле');
|
||||
}
|
||||
entity.fields = entity.fields.filter((f) => f.id !== fieldId);
|
||||
await putStoreItem('entities_meta', entity);
|
||||
|
|
@ -116,15 +122,18 @@ export class MockMetadataService implements IMetadataService {
|
|||
}
|
||||
|
||||
// Layouts
|
||||
async listLayouts(entityCode: string): Promise<LayoutDefinition[]> {
|
||||
async listLayouts(entityCode: string, tenantId?: string): Promise<LayoutDefinition[]> {
|
||||
const effectiveTenant = tenantId || getActiveGlobalTenantId();
|
||||
const db = await getDb();
|
||||
const tx = db.transaction('layouts_meta', 'readonly');
|
||||
const index = tx.store.index('by_entity');
|
||||
const items = (await index.getAll(entityCode)) as LayoutDefinition[];
|
||||
if (items.length > 0) return items;
|
||||
const items = (await index.getAll(entityCode)) as (LayoutDefinition & { tenant_id?: string })[];
|
||||
const filtered = items.filter(
|
||||
(l) => !l.tenant_id || l.tenant_id === 'system' || l.tenant_id === effectiveTenant
|
||||
);
|
||||
if (filtered.length > 0) return filtered;
|
||||
|
||||
// Return a default fallback layout
|
||||
const entity = await this.getEntity(entityCode);
|
||||
const entity = await this.getEntity(entityCode, effectiveTenant);
|
||||
return [
|
||||
{
|
||||
id: `layout_def_${entityCode}`,
|
||||
|
|
@ -147,8 +156,13 @@ export class MockMetadataService implements IMetadataService {
|
|||
return getStoreItem<LayoutDefinition>('layouts_meta', id);
|
||||
}
|
||||
|
||||
async saveLayout(layout: LayoutDefinition): Promise<LayoutDefinition> {
|
||||
await putStoreItem('layouts_meta', layout);
|
||||
async saveLayout(layout: LayoutDefinition, tenantId?: string): Promise<LayoutDefinition> {
|
||||
const effectiveTenant = tenantId || getActiveGlobalTenantId();
|
||||
const withTenant = {
|
||||
...layout,
|
||||
tenant_id: (layout as unknown as Record<string, unknown>).tenant_id || effectiveTenant,
|
||||
};
|
||||
await putStoreItem('layouts_meta', withTenant, effectiveTenant);
|
||||
return layout;
|
||||
}
|
||||
|
||||
|
|
@ -158,14 +172,18 @@ export class MockMetadataService implements IMetadataService {
|
|||
}
|
||||
|
||||
// Views
|
||||
async listViews(entityCode: string): Promise<ViewDefinition[]> {
|
||||
async listViews(entityCode: string, tenantId?: string): Promise<ViewDefinition[]> {
|
||||
const effectiveTenant = tenantId || getActiveGlobalTenantId();
|
||||
const db = await getDb();
|
||||
const tx = db.transaction('views_meta', 'readonly');
|
||||
const index = tx.store.index('by_entity');
|
||||
const items = (await index.getAll(entityCode)) as ViewDefinition[];
|
||||
if (items.length > 0) return items;
|
||||
const items = (await index.getAll(entityCode)) as (ViewDefinition & { tenant_id?: string })[];
|
||||
const filtered = items.filter(
|
||||
(v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === effectiveTenant
|
||||
);
|
||||
if (filtered.length > 0) return filtered;
|
||||
|
||||
const entity = await this.getEntity(entityCode);
|
||||
const entity = await this.getEntity(entityCode, effectiveTenant);
|
||||
return [
|
||||
{
|
||||
id: `view_def_${entityCode}`,
|
||||
|
|
@ -183,8 +201,13 @@ export class MockMetadataService implements IMetadataService {
|
|||
return getStoreItem<ViewDefinition>('views_meta', id);
|
||||
}
|
||||
|
||||
async saveView(view: ViewDefinition): Promise<ViewDefinition> {
|
||||
await putStoreItem('views_meta', view);
|
||||
async saveView(view: ViewDefinition, tenantId?: string): Promise<ViewDefinition> {
|
||||
const effectiveTenant = tenantId || getActiveGlobalTenantId();
|
||||
const withTenant = {
|
||||
...view,
|
||||
tenant_id: (view as unknown as Record<string, unknown>).tenant_id || effectiveTenant,
|
||||
};
|
||||
await putStoreItem('views_meta', withTenant, effectiveTenant);
|
||||
return view;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { openDB, IDBPDatabase } from 'idb';
|
||||
import { storageEventBus } from './eventBus';
|
||||
import {
|
||||
TenantAccessDeniedError,
|
||||
DEFAULT_TENANT_ID,
|
||||
getActiveGlobalTenantId,
|
||||
} from '../contracts/context';
|
||||
import { ulid } from '../../shared/lib/ulid';
|
||||
|
||||
export const DB_NAME = 'krona_business_os_db';
|
||||
export const DB_VERSION = 2;
|
||||
|
|
@ -19,6 +25,7 @@ export type DbStoreName = (typeof DB_OBJECT_STORES)[number];
|
|||
export interface RecordEntity {
|
||||
id: string;
|
||||
entityType: string;
|
||||
tenant_id?: string;
|
||||
updatedAt?: string;
|
||||
audit?: {
|
||||
createdAt: string;
|
||||
|
|
@ -26,6 +33,8 @@ export interface RecordEntity {
|
|||
updatedAt: string;
|
||||
updatedById: string;
|
||||
version: number;
|
||||
tenantId?: string;
|
||||
correlationId?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -35,6 +44,7 @@ export interface DatabaseSnapshot {
|
|||
schemaVersion: number;
|
||||
exportedAt: string;
|
||||
system: string;
|
||||
tenantId?: string;
|
||||
stores: {
|
||||
entities_meta: unknown[];
|
||||
fields_meta: unknown[];
|
||||
|
|
@ -98,7 +108,7 @@ export function getDb(): Promise<IDBPDatabase> {
|
|||
s.createIndex('by_entity', 'entityCode', { unique: false });
|
||||
}
|
||||
|
||||
// 5. records_data (with indexes on entityType, id, updatedAt)
|
||||
// 5. records_data (with indexes on entityType, id, updatedAt, tenant_id)
|
||||
if (!db.objectStoreNames.contains('records_data')) {
|
||||
const s = db.createObjectStore('records_data', { keyPath: 'id' });
|
||||
s.createIndex('by_entity_type', 'entityType', { unique: false });
|
||||
|
|
@ -124,19 +134,31 @@ export function getDb(): Promise<IDBPDatabase> {
|
|||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// CRUD on records_data
|
||||
// CRUD on records_data with Tenant Isolation
|
||||
// ----------------------------------------------------
|
||||
|
||||
export async function getRecord<T = RecordEntity>(id: string): Promise<T | null> {
|
||||
export async function getRecord<T = RecordEntity>(id: string, tenantId?: string): Promise<T | null> {
|
||||
const db = await getDb();
|
||||
const res = await db.get('records_data', id);
|
||||
return (res as T) || null;
|
||||
if (!res) return null;
|
||||
|
||||
const rec = res as RecordEntity;
|
||||
const effectiveTenantId = tenantId || getActiveGlobalTenantId();
|
||||
if (effectiveTenantId && rec.tenant_id && rec.tenant_id !== effectiveTenantId) {
|
||||
throw new TenantAccessDeniedError(
|
||||
`Отказано в доступе: запись «${id}» принадлежит тенанту «${rec.tenant_id}», текущий: «${effectiveTenantId}»`
|
||||
);
|
||||
}
|
||||
|
||||
return res as T;
|
||||
}
|
||||
|
||||
export async function putRecord<T extends RecordEntity>(record: T): Promise<T> {
|
||||
export async function putRecord<T extends RecordEntity>(record: T, tenantId?: string): Promise<T> {
|
||||
const db = await getDb();
|
||||
const effectiveTenantId = record.tenant_id || tenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
const clean = {
|
||||
...record,
|
||||
tenant_id: effectiveTenantId,
|
||||
updatedAt: record.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
await db.put('records_data', clean);
|
||||
|
|
@ -150,11 +172,19 @@ export async function putRecord<T extends RecordEntity>(record: T): Promise<T> {
|
|||
return clean as T;
|
||||
}
|
||||
|
||||
export async function deleteRecord(id: string, entityType?: string): Promise<boolean> {
|
||||
export async function deleteRecord(id: string, entityType?: string, tenantId?: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const existing = await db.get('records_data', id);
|
||||
if (!existing) return false;
|
||||
|
||||
const rec = existing as RecordEntity;
|
||||
const effectiveTenantId = tenantId || getActiveGlobalTenantId();
|
||||
if (effectiveTenantId && rec.tenant_id && rec.tenant_id !== effectiveTenantId) {
|
||||
throw new TenantAccessDeniedError(
|
||||
`Отказано в доступе: запись «${id}» принадлежит тенанту «${rec.tenant_id}», текущий: «${effectiveTenantId}»`
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete('records_data', id);
|
||||
storageEventBus.emit('records:changed', {
|
||||
storeName: 'records_data',
|
||||
|
|
@ -173,12 +203,22 @@ export async function getRecordsByEntity<T = RecordEntity>(
|
|||
sort?: { field: string; order: 'asc' | 'desc' };
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
tenantId?: string;
|
||||
}
|
||||
): Promise<{ items: T[]; total: number; page: number; pageSize: number; totalPages: number }> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction('records_data', 'readonly');
|
||||
const index = tx.store.index('by_entity_type');
|
||||
let all = (await index.getAll(entityType)) as T[];
|
||||
let all = (await index.getAll(entityType)) as (T & RecordEntity)[];
|
||||
|
||||
// 0. Tenant isolation filter
|
||||
const targetTenantId = options?.tenantId !== undefined ? options.tenantId : getActiveGlobalTenantId();
|
||||
if (targetTenantId) {
|
||||
all = all.filter((item) => {
|
||||
const itemTenant = item.tenant_id || DEFAULT_TENANT_ID;
|
||||
return itemTenant === targetTenantId;
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Filter
|
||||
if (options?.filter) {
|
||||
|
|
@ -235,18 +275,22 @@ export async function getRecordsByEntity<T = RecordEntity>(
|
|||
return { items, total, page, pageSize, totalPages };
|
||||
}
|
||||
|
||||
export async function countRecords(entityType?: string): Promise<number> {
|
||||
export async function countRecords(entityType?: string, tenantId?: string): Promise<number> {
|
||||
const effectiveTenantId = tenantId !== undefined ? tenantId : getActiveGlobalTenantId();
|
||||
if (entityType) {
|
||||
const res = await getRecordsByEntity(entityType, { tenantId: effectiveTenantId });
|
||||
return res.total;
|
||||
}
|
||||
const db = await getDb();
|
||||
if (!entityType) {
|
||||
if (!effectiveTenantId) {
|
||||
return db.count('records_data');
|
||||
}
|
||||
const tx = db.transaction('records_data', 'readonly');
|
||||
const index = tx.store.index('by_entity_type');
|
||||
return index.count(entityType);
|
||||
const all = (await db.getAll('records_data')) as RecordEntity[];
|
||||
return all.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === effectiveTenantId).length;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Generic Store Helpers
|
||||
// Generic Store Helpers with Optional Tenant Tagging
|
||||
// ----------------------------------------------------
|
||||
|
||||
export async function getStoreItem<T>(storeName: DbStoreName, key: string): Promise<T | null> {
|
||||
|
|
@ -255,12 +299,18 @@ export async function getStoreItem<T>(storeName: DbStoreName, key: string): Prom
|
|||
return (res as T) || null;
|
||||
}
|
||||
|
||||
export async function putStoreItem<T>(storeName: DbStoreName, item: T): Promise<void> {
|
||||
export async function putStoreItem<T>(storeName: DbStoreName, item: T, tenantId?: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put(storeName, item);
|
||||
const effectiveTenant = tenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
const cleanItem =
|
||||
typeof item === 'object' && item !== null
|
||||
? { ...item, tenant_id: (item as Record<string, unknown>).tenant_id || effectiveTenant }
|
||||
: item;
|
||||
|
||||
await db.put(storeName, cleanItem);
|
||||
storageEventBus.emit('entity:changed', {
|
||||
storeName,
|
||||
data: item,
|
||||
data: cleanItem,
|
||||
action: 'update',
|
||||
});
|
||||
}
|
||||
|
|
@ -275,9 +325,26 @@ export async function deleteStoreItem(storeName: DbStoreName, key: string): Prom
|
|||
});
|
||||
}
|
||||
|
||||
export async function getAllFromStore<T>(storeName: DbStoreName): Promise<T[]> {
|
||||
export async function getAllFromStore<T>(storeName: DbStoreName, tenantId?: string): Promise<T[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll(storeName);
|
||||
const all = (await db.getAll(storeName)) as (T & Record<string, unknown>)[];
|
||||
|
||||
if (!tenantId) {
|
||||
return all as T[];
|
||||
}
|
||||
|
||||
// Filter items by tenant
|
||||
if (storeName === 'entities_meta' || storeName === 'layouts_meta' || storeName === 'views_meta') {
|
||||
return all.filter(
|
||||
(item) => !item.tenant_id || item.tenant_id === 'system' || item.tenant_id === tenantId
|
||||
) as T[];
|
||||
}
|
||||
|
||||
if (storeName === 'audit_log' || storeName === 'records_data') {
|
||||
return all.filter((item) => (item.tenant_id || DEFAULT_TENANT_ID) === tenantId) as T[];
|
||||
}
|
||||
|
||||
return all as T[];
|
||||
}
|
||||
|
||||
export async function countStore(storeName: DbStoreName): Promise<number> {
|
||||
|
|
@ -291,25 +358,41 @@ export async function clearStore(storeName: DbStoreName): Promise<void> {
|
|||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Snapshot Export / Import / Reset
|
||||
// Snapshot Export / Import / Reset with Tenant Support
|
||||
// ----------------------------------------------------
|
||||
|
||||
export async function exportDatabaseSnapshot(): Promise<DatabaseSnapshot> {
|
||||
export async function exportDatabaseSnapshot(tenantId?: string): Promise<DatabaseSnapshot> {
|
||||
const db = await getDb();
|
||||
|
||||
const entities_meta = await db.getAll('entities_meta');
|
||||
const fields_meta = await db.getAll('fields_meta');
|
||||
const layouts_meta = await db.getAll('layouts_meta');
|
||||
const views_meta = await db.getAll('views_meta');
|
||||
const records_data = await db.getAll('records_data');
|
||||
const audit_log = await db.getAll('audit_log');
|
||||
const settings = await db.getAll('settings');
|
||||
let entities_meta = (await db.getAll('entities_meta')) as Record<string, unknown>[];
|
||||
let fields_meta = (await db.getAll('fields_meta')) as Record<string, unknown>[];
|
||||
let layouts_meta = (await db.getAll('layouts_meta')) as Record<string, unknown>[];
|
||||
let views_meta = (await db.getAll('views_meta')) as Record<string, unknown>[];
|
||||
let records_data = (await db.getAll('records_data')) as RecordEntity[];
|
||||
let audit_log = (await db.getAll('audit_log')) as Record<string, unknown>[];
|
||||
let settings = (await db.getAll('settings')) as Record<string, unknown>[];
|
||||
|
||||
if (tenantId) {
|
||||
records_data = records_data.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === tenantId);
|
||||
audit_log = audit_log.filter((a) => (a.tenant_id || DEFAULT_TENANT_ID) === tenantId);
|
||||
entities_meta = entities_meta.filter(
|
||||
(e) => !e.tenant_id || e.tenant_id === 'system' || e.tenant_id === tenantId
|
||||
);
|
||||
layouts_meta = layouts_meta.filter(
|
||||
(l) => !l.tenant_id || l.tenant_id === 'system' || l.tenant_id === tenantId
|
||||
);
|
||||
views_meta = views_meta.filter(
|
||||
(v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId
|
||||
);
|
||||
settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
schemaVersion: DB_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
system: 'Krona Universal Business OS',
|
||||
tenantId: tenantId || 'all',
|
||||
stores: {
|
||||
entities_meta,
|
||||
fields_meta,
|
||||
|
|
@ -327,7 +410,10 @@ export async function exportDatabaseSnapshot(): Promise<DatabaseSnapshot> {
|
|||
};
|
||||
}
|
||||
|
||||
export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promise<void> {
|
||||
export async function importDatabaseSnapshot(
|
||||
snapshot: DatabaseSnapshot,
|
||||
targetTenantId?: string
|
||||
): Promise<void> {
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
throw new Error('Некорректный формат снимка базы данных (snapshot).');
|
||||
}
|
||||
|
|
@ -338,12 +424,77 @@ export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promis
|
|||
const db = await getDb();
|
||||
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
||||
|
||||
for (const store of DB_OBJECT_STORES) {
|
||||
const storeObj = tx.objectStore(store);
|
||||
await storeObj.clear();
|
||||
const items = snapshot.stores[store] || [];
|
||||
for (const item of items) {
|
||||
await storeObj.put(item);
|
||||
if (!targetTenantId) {
|
||||
// Global wipe & restore
|
||||
for (const store of DB_OBJECT_STORES) {
|
||||
const storeObj = tx.objectStore(store);
|
||||
await storeObj.clear();
|
||||
const items = snapshot.stores[store] || [];
|
||||
for (const item of items) {
|
||||
await storeObj.put(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Isolated tenant import: delete only target tenant records, keep other tenants safe
|
||||
const recordsStore = tx.objectStore('records_data');
|
||||
const existingRecords = (await recordsStore.getAll()) as RecordEntity[];
|
||||
for (const r of existingRecords) {
|
||||
if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
||||
await recordsStore.delete(r.id);
|
||||
}
|
||||
}
|
||||
const newRecords = (snapshot.stores.records_data || []) as RecordEntity[];
|
||||
const idMap = new Map<string, string>();
|
||||
for (const r of newRecords) {
|
||||
const isCrossTenantClone = Boolean(
|
||||
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
||||
);
|
||||
const idConflict = existingRecords.some(
|
||||
(er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId
|
||||
);
|
||||
const newId = isCrossTenantClone || idConflict ? ulid() : r.id;
|
||||
idMap.set(r.id, newId);
|
||||
await recordsStore.put({ ...r, id: newId, tenant_id: targetTenantId });
|
||||
}
|
||||
|
||||
// Audit log
|
||||
const auditStore = tx.objectStore('audit_log');
|
||||
const existingAudit = (await auditStore.getAll()) as Record<string, unknown>[];
|
||||
for (const a of existingAudit) {
|
||||
if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
||||
await auditStore.delete(String(a.id));
|
||||
}
|
||||
}
|
||||
const newAudit = (snapshot.stores.audit_log || []) as Record<string, unknown>[];
|
||||
for (const a of newAudit) {
|
||||
const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId;
|
||||
const isCrossTenantClone = Boolean(
|
||||
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
||||
);
|
||||
const newId = isCrossTenantClone ? ulid() : (a.id as string);
|
||||
await auditStore.put({
|
||||
...a,
|
||||
id: newId,
|
||||
entityId: mappedEntityId,
|
||||
tenant_id: targetTenantId,
|
||||
});
|
||||
}
|
||||
|
||||
// Metadata: entities, fields, layouts, views
|
||||
for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) {
|
||||
const s = tx.objectStore(store);
|
||||
const items = (snapshot.stores[store] || []) as Record<string, unknown>[];
|
||||
for (const item of items) {
|
||||
if (!item.tenant_id || item.tenant_id === 'system') {
|
||||
await s.put(item);
|
||||
} else {
|
||||
const isCrossTenantClone = Boolean(
|
||||
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
||||
);
|
||||
const newId = isCrossTenantClone && item.id ? ulid() : item.id;
|
||||
await s.put({ ...item, id: newId, tenant_id: targetTenantId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -355,13 +506,34 @@ export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promis
|
|||
});
|
||||
}
|
||||
|
||||
export async function resetDatabase(): Promise<void> {
|
||||
export async function resetDatabase(tenantId?: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
||||
for (const store of DB_OBJECT_STORES) {
|
||||
await tx.objectStore(store).clear();
|
||||
|
||||
if (!tenantId) {
|
||||
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
||||
for (const store of DB_OBJECT_STORES) {
|
||||
await tx.objectStore(store).clear();
|
||||
}
|
||||
await tx.done;
|
||||
} else {
|
||||
// Only wipe data belonging to the specified tenant
|
||||
const tx = db.transaction(['records_data', 'audit_log'] as const, 'readwrite');
|
||||
const recordsStore = tx.objectStore('records_data');
|
||||
const allRecords = (await recordsStore.getAll()) as RecordEntity[];
|
||||
for (const r of allRecords) {
|
||||
if ((r.tenant_id || DEFAULT_TENANT_ID) === tenantId) {
|
||||
await recordsStore.delete(r.id);
|
||||
}
|
||||
}
|
||||
const auditStore = tx.objectStore('audit_log');
|
||||
const allAudit = (await auditStore.getAll()) as Record<string, unknown>[];
|
||||
for (const a of allAudit) {
|
||||
if ((a.tenant_id || DEFAULT_TENANT_ID) === tenantId) {
|
||||
await auditStore.delete(String(a.id));
|
||||
}
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
await tx.done;
|
||||
|
||||
storageEventBus.emit('storage:reset', {
|
||||
action: 'reset',
|
||||
|
|
|
|||
Loading…
Reference in a new issue